speechSynthesis is silently muted on iOS Safari and Chrome Android after
the audio session ends (onended), so chapter announcements never played.
Fix:
- Add GET /api/tts-announce backend endpoint: streams a short TTS clip
for arbitrary text without MinIO caching (backend/internal/backend/)
- Add GET /api/announce SvelteKit proxy route (no paywall)
- Add announceNavigatePending/announcePendingSlug/announcePendingChapter
to AudioStore
- Rewrite onended announce branch: sets audioStore.audioUrl to the
announcement clip URL so the persistent <audio> element plays it;
the next onended detects announceNavigatePending and navigates
- 10s safety timeout in case the clip fails to load/end
CI svelte-check failed because Paraglide generates per-key .js files that
must be committed — the compiler runs at build time in CI (svelte-kit sync)
but the output directory is tracked in git, so new keys added to messages/*.json
require the corresponding generated files to be committed manually when node
is not available locally.
Added:
- messages/profile_theme_forest.js
- messages/profile_theme_mono.js
- messages/profile_theme_cyber.js
- messages/_index.js: three new export lines
Also fixed a11y warn: added aria-label='Close history' to the icon-only
close button in the discover page history drawer.
Forest — deep green surfaces (#0a130d base) with green-400 accent.
Inspired by terminal emulators and dark mode IDE forest schemes.
Mono — near-black zinc-950 surface with pure white/zinc-100 accent.
Minimal high-contrast monochromatic palette for distraction-free reading.
Cyber — near-black blue surface with neon cyan-400 accent and dracula-palette
success/danger colors. Cyberpunk aesthetic with neon glow feel.
Changes:
- app.css: three new [data-theme] blocks with full token sets
- +layout.svelte: added forest/mono/cyber to THEMES array (before light themes)
- profile/+page.svelte: same additions with i18n label functions
- messages/*.json: translated names in all 5 locales (en/fr/ru/pt/id)
Root cause: the chapter picker overlay (fixed inset-0) was rendered as a
descendant of the AudioPlayer's wrapping div, which is itself inside a
'rounded-b-lg overflow-hidden' container on the chapter page. Chrome clips
position:fixed descendants when a parent has overflow:hidden + border-radius,
so the overlay was constrained to the parent's bounding box instead of
covering the full viewport.
Fix: moved the {#if showChapterPanel} block from inside the standard player
div to a top-level sibling at the end of the component template. Svelte
components support multiple root nodes, so the overlay is now a sibling of
all player containers — no ancestor overflow-hidden can clip it.
Also replaced the subtle chevron-down close button with a clear X (×) button
in the top-right of the header, making it obvious how to dismiss the panel.
Two issues causing announce to silently fail or permanently block navigation:
1. No hard timeout fallback on speechSynthesis.speak():
Chrome Android (and some desktop) silently drops utterances not triggered
within a user-gesture window. If both onend and onerror fail to fire (a
known browser bug), doNavigate() was never called and the chapter
transition was permanently lost. Added an 8-second setTimeout fallback
(safeNavigate) that forces navigation if the speech engine never resolves.
safeNavigate is idempotent — guarded by a 'navigated' flag so it only
fires once even if onend, onerror, and the timeout all fire.
2. audioStore.chapters only written inside startPlayback():
The onended handler reads audioStore.chapters to build the utterance text
(Chapter N — Title). If auto-next navigated to this chapter and the user
never manually pressed play (startPlayback was never called), chapters
held whatever the previous AudioPlayer had written — potentially stale or
empty on a book switch. Added a reactive $effect that keeps chapters in
sync whenever the prop changes, same pattern as nextChapter.
Bug 1 — Auto-next not transitioning:
audioExpanded defaulted to false on the new chapter page because
audioStore.chapter still held the old chapter number when the page script
initialized. The $effect only opened the panel when isPlaying was already
true — a circular dependency (can't play without the panel, panel only opens
when playing). Fix: also set audioExpanded=true when autoStartChapter targets
this chapter, both in the initial $state and in the reactive $effect.
Bug 2 — Resume starts at the end:
onended called saveAudioTime() which captured currentTime≈duration and fired a
PATCH 2 seconds later (after navigation had already completed). Next visit to
that chapter restored the end-of-file position. Fix: in onended, cancel the
debounced timer (clearTimeout) and immediately PATCH audioTime=0 for the
finished chapter, so it always resumes from the beginning on re-visit.
doAction() was fire-and-forgetting the POST but never updating the client-side
votedBooks array. History was only populated from SSR data.votedBooks (loaded
at page init), so any votes cast during the current session were invisible in
the drawer until a full page reload. Now we prepend/replace an entry in
votedBooks optimistically the moment a swipe action fires.
- Remove tab switcher, move history behind a modal drawer (clock icon in header with badge count)
- Increase card aspect ratio from 3/4.2 to 3/4.6 for more cover real estate
- Replace 5 small icon-only buttons with 3 large labeled buttons (Skip / Read Now / Like)
- Read Now is solid blue as the center primary CTA; Skip and Like use tinted bg with colored border
- Swipe indicators are larger (text-2xl, border-[3px], bg tint) for better visibility
- Remove swipe hint text to reclaim vertical space
- Larger title text on card (text-2xl)
When enabled, the Web Speech API speaks the upcoming chapter number and
title (e.g. 'Chapter 12 — The Final Battle') between auto-next chapters,
giving an audible cue before the next narration begins.
- AudioStore.announceChapter ( boolean, default false)
- PBUserSettings.announce_chapter persisted to PocketBase
- GET/PUT /api/settings includes announceChapter field
- +layout.server.ts loads + defaults the field
- +layout.svelte applies on load, saves in debounced PUT, and fires
SpeechSynthesisUtterance in onended before navigating (falls back to
immediate navigation if speechSynthesis is unavailable)
- ListeningMode: 'Announce' pill added to the Speed · Auto · Sleep row
Replace hardcoded bg-amber-500/text-zinc-900 with bg-(--color-brand)/text-(--color-surface)
to match the rest of the UI's button palette (both grid and list views).
- Add 30s Valkey cache to listScrapingTasks, listAudioJobs, listTranslationJobs
(use listN(500) instead of unbounded listAll to cap at one request)
- Delete listAudioCache() — derive AudioCacheEntry[] from jobs in server load
- Add listBookSlugs() with 10min cache — replaces full listBooks() in translation load
- Add GET /api/admin/audio-jobs, /api/admin/translation-jobs, /api/admin/scrape-tasks
(lightweight polling endpoints backed by the Valkey cache)
- Replace invalidateAll() interval polling in audio+translation pages with
targeted fetch to the new endpoints (avoids re-running full server load)
- Add POST /api/admin/image-gen/async: fire-and-forget image generation
that stores the result (base64) in an ai_job payload and returns 202
immediately — no more 60-120s blocking on FLUX models
- Add POST /api/admin/text-gen/description/async: same pattern for book
description generation
- Register both new routes in server.go
- Rewrite image-gen admin page to use the async path (submit → redirect
to AI Jobs for monitoring)
- Extend ai-jobs page with Review panels for image-gen jobs (show image,
Save as cover / Download / Discard) and description jobs (diff old vs
new, editable textarea, Apply / Discard)
- New SearchModal.svelte: full-screen modal with blurred backdrop
- Live results as you type (300ms debounce, min 2 chars)
- Local vs Novelfire badge on each result card (cover + title + author +
genres + chapter count)
- Local/remote counts shown in result header
- 'See all in catalogue' shortcut button + footer repeat link
- Recent searches (localStorage, max 8, per-item remove + clear all)
- Genre suggestion chips shown when query is empty or no results found
- Keyboard navigation: ArrowUp/Down to select, Enter to open, Escape to close
- Body scroll lock while open
- +layout.svelte:
- Imports SearchModal, adds searchOpen state
- Search icon button in nav header (hidden on chapter reader pages)
- Global keyboard shortcut: '/' or Cmd/Ctrl+K opens modal
- Shortcut ignored when focused in input/textarea or on chapter pages
- Modal not shown while ListeningMode is open
- Auto-closes on route change
In paginated + focus mode there were two separate UI elements: an inline
Prev/counter/Next bar and a floating chapter-nav pill. Merged into one:
- ‹ Ch.N | ‹ (page) N/M (page) › | × Exit focus | Ch.N ›
- Inline page bar + hint text are now hidden when focusMode is active
- Floating pill grows to include page controls only in paginated mode;
scroll mode pill is unchanged (just chapter nav + exit)
- Added max-w-[calc(100vw-2rem)] so pill never overflows on small screens
- Replace double-triangle icons with proper skip-prev (|◄) and skip-next (►|) icons
- Convert prev/next chapter <a> links to buttons calling playChapter() so navigation auto-starts audio
- Fix auto-next silent failure: fast path A now re-presigns instead of reusing the cached URL, preventing stale/expired MinIO presigned URL from silently failing on the audio element
Replace the full-bleed landscape hero with the Apple Music / Spotify
layout pattern:
- Full-screen blurred+darkened cover as atmospheric background layer
- Centered portrait 2/3 cover card (38svh tall, rounded-2xl, shadow-2xl)
- Track info (chapter label, title, book name) moved below cover card
- Radial vignette overlay for depth
- No dead empty space between art and controls
- Header bar and controls area lifted to z-index 2 above the bg layers
- Add pointer-events:none to ListeningMode fly-transition wrapper div in
+layout.svelte so the exiting animation div never blocks page interaction
- Add pointer-events:auto to ListeningMode root div so it still captures
all touch/click events correctly despite the parent being pointer-events:none
- Rewrite carousel auto-advance using $effect + autoAdvanceSeed pattern:
replaces the stale-closure setInterval in resetAutoAdvance() with a
reactive $effect that owns the interval and re-starts cleanly on manual
navigation by bumping a seed counter
Svelte 5 has no |nonpassive modifier. Register the touchmove listener
manually so e.preventDefault() can suppress page scroll during the
pull-down gesture.
- Slide-up transition on open (fly from bottom, 320ms)
- Drag-down on the overlay follows the finger in real time with no
transition; on release springs back (0.32s cubic-bezier) if drag
< 130px and velocity < 0.4px/ms, otherwise slides off-screen and
calls onclose after 220ms
- Opacity fades as the overlay is pulled down (fully transparent at 500px)
- Touch guard: gesture does not activate if touch starts inside an
.overflow-y-auto element (chapter/voice lists) or while a modal is open
- Full-bleed cover fills top ~52% of screen with top+bottom gradient
overlays for header and track info legibility; eliminates the large
dead space between cover and seek bar
- Chapter number shown as brand-coloured label above the chapter title
- Remaining time (−m:ss) displayed in the centre of the seek bar row
- Transport row uses justify-between; chapter-skip buttons are smaller
(w-5, muted/60 opacity) vs time-skip (w-7, full muted) to clearly
distinguish secondary from primary seek controls
- Speed / Auto-next / Sleep now sit on a single tidy row — no more
wrapping or mixed visual styles between the segmented speed control
and the two pill buttons
- Header buttons use frosted-glass style (bg-black/25 backdrop-blur)
so they remain legible over the cover image
- Hide the global site footer on chapter pages (not useful mid-reading)
- Merge the three separate floating nav pills into a single unified pill
with dividers, removing the visual clutter of multiple bordered bubbles
- Float the pill lower (bottom-6) when the mini-player is not active
Adds a PageLines preference (Few/Normal/Many) that adjusts the paginated
container height via a rem offset on the existing calc(). The setting row
appears in Reader Settings → Layout only when Pages mode is active, matching
the style of all other setting rows. Persisted in localStorage (reader_layout_v1).
Use a Svelte action on each chapter button that calls scrollIntoView with
behavior:'instant' so the list opens centred on the active chapter with
no visible scroll animation.
Reading audioStore.isPlaying inside the toggleRequest $effect caused Svelte 5
to subscribe to it, so the effect re-ran on every isPlaying change. When
resuming from ListeningMode, play() would fire onplay → isPlaying=true →
effect re-ran → called pause() → onpause → isPlaying=false → effect re-ran
→ called play() → infinite loop. Wrapping the isPlaying read in untrack()
limits the effect's subscription to toggleRequest only.
Cycles through all in-progress books every 6s; prev/next arrow buttons
overlay the card edges; active dot stretches to a pill; cover fades in
on slide change via {#key} + animate-fade-in; shelf excludes the current
hero to avoid duplication.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- justify-between on scrollable body so cover art sits top, controls
sit bottom — no more half-empty screen on tall phones
- Move ListeningMode outside {#if audioStore.active} so pausing never
tears down the overlay and loses resume context
- Mini-bar time/track click now opens ListeningMode with chapter picker
pre-shown (same view as the Chapters button inside ListeningMode)
- Remove the old chapterDrawerOpen mini-bar drawer (replaced by above)
- Add openChapters prop to ListeningMode for pre-opening chapter modal
Replaces the bottom drawer + backdrop with a fixed full-screen overlay
matching the voice/chapter picker style in ListeningMode — chevron header,
tab bar with brand-color active state, scrollable content. Escape closes it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Both ListeningMode and the standard AudioPlayer now open chapters via a
full-screen overlay (same UX as the voice selector) — header + search bar +
rows with circular chapter-number badge, title, and active indicator.
Removes the cramped inline card from the bottom of ListeningMode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents the silly "2:01 / 0:00" display when audio src is being swapped
from preview to full audio and duration hasn't loaded yet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Strip leading digit prefix (e.g. "6Chapter 6 → Chapter 6") and
content after first newline (scraped date artifacts) from chapter titles
- Add "CHAPTER N" eyebrow label above the h1 for clear hierarchy
- Show date_label as small muted text in the meta row
- Remove double-border / mt-6 gap from standard AudioPlayer inside the
chapter page's collapsible panel (was rendering two nested boxes)
- Remove redundant "Audio Narration" label (toggle already says "Listen")
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- deviceFingerprint now hashes only User-Agent (not UA+IP) so switching
networks (VPN, mobile data, wifi) no longer creates a new session row
- On re-login with same device, also refresh the stored IP field so the
sessions page shows the current network address
- feat(library): bulk remove and bulk shelf-change actions on /books
Long-press any card to enter selection mode; sticky action bar with
Move to shelf dropdown and Remove button; POST /api/library/bulk-remove
and POST /api/library/bulk-shelf endpoints
- fix(catalogue): make Scrape button visible with solid amber-500 fill
and dark text instead of low-opacity ghost style that blended into card
The description was crammed into the narrow right column beside the cover,
creating a wall of text on mobile. Now it renders full-width below the
cover+title row with better line-height, 5-line collapse, gradient fade,
and a chevron-annotated show-more button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add POST /api/admin/text-gen/chapter-names/async backend endpoint: fire-and-forget,
returns job_id immediately (HTTP 202), runs batch generation in background goroutine,
persists proposed titles in ai_job payload when done
- Register new route in server.go alongside existing SSE endpoint (backward compat)
- Add SvelteKit proxy at /api/admin/text-gen/chapter-names/async
- Add SvelteKit proxy for GET /api/admin/ai-jobs/[id] (job detail with payload)
- Add Review button on ai-jobs page for done chapter-names jobs; inline panel shows
editable title table (old to new) with Apply All button that POSTs to chapter-names/apply
- Backend: add GET /api/audio-preview/{slug}/{n} — generates first ~1800-char
chunk via CF AI so playback starts immediately; full chapter cached in MinIO
- Frontend: replace CF AI spinner with preview blob URL + background swap to
full presigned URL when runner finishes, preserving currentTime
- AudioPlayer: isPreview state + 'preview' badge in mini-bar during swap
- pocketbase.ts: fix 403 on stale token — reduce TTL to 50 min + retry once
on 401/403 with forced re-auth (was cached 12 h, PB tokens expire in 1 h)
- Footer build time now rendered in user's local timezone via toLocaleString()
When Valkey is unreachable, ioredis was holding cache.get() calls in
the offline queue for the default 10s connectTimeout before failing.
This caused admin/image-gen and text-gen pages to stall on every load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Speed and auto-next are already available in the full listening mode
overlay — no need to clutter the compact bottom bar with them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When PREBUILT=1 the pre-built artifact is downloaded into ui/build/ but
.dockerignore excludes 'build', so Docker never sees it and /app/build
doesn't exist in the builder stage — causing the runtime COPY to fail.
Fix: rewrite ui/.dockerignore on the CI runner (grep -v '^build$') so the
pre-built directory is included in the Docker context.
Also in this commit:
- book page: gate EPUB download on isPro (UI upsell + server 403 guard)
- book page: chapter names default pattern changed to '{scene}'
When the mini-player is active on the current chapter, the collapsible
'Listen to this chapter' panel now shows a brief note instead of rendering
a full second AudioPlayer.
Bottom bar track info column: removed chapter title and book title lines
so the time display fits on one line without crowding the controls.
- Add /admin/ai-jobs page with live-polling jobs table, status badges, progress bars, and cancel action
- Add listAIJobs() helper in pocketbase.ts; AIJob type
- Add POST /api/admin/ai-jobs/[id]/cancel SvelteKit proxy
- Add ai-jobs link to admin sidebar nav (all 5 locales)
- Cache image-gen and text-gen model lists in Valkey (10 min TTL) via scraper.ts helpers
- Cache changelog Gitea API response (5 min TTL)
- Improve 502/504 error message on image-gen page with CF AI timeout hint
- Show CF AI timeout warning in advanced options when a Cloudflare/FLUX model is selected
- Consolidate top nav into a single row: ← back | ← → chapter arrows | settings gear
Removes the duplicate prev/next buttons that appeared at both top and bottom
- Language switcher moved inline into chapter meta line (after word count);
lock icons made smaller and less distracting for free users; removes the
separate "Upgrade to Pro" text link
- Audio player wrapped in a collapsible "Listen to this chapter" panel;
auto-expands if audio is already playing for the chapter, collapsed otherwise
so content is immediately visible on page load
- Bottom nav redesigned: next chapter is a full-width card CTA with hover
accent, previous is a small secondary text link — clear visual hierarchy
- Remove floating gear button (settings now triggered from top nav settings icon)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add homelab/glitchtip/files_api.py with GzipChunk fallback for sentry-cli 3.x
raw zip uploads; bind-mount into both glitchtip-web and glitchtip-worker
- Add release.yaml prune step to delete all but the 10 newest GlitchTip releases
- Reader page: remove dead code and simplify layout
Adds a full-screen listening mode accessible via the headphones button in the
mini-player bar. Moves voice selector, speed, auto-next, and sleep timer out of
the reader settings panel into the new overlay. Voices are stored in AudioStore
so ListeningMode can read them without prop drilling.
- New `ai_jobs` PocketBase collection tracks all long-running AI tasks
(batch-covers, chapter-names) with status, progress, and cancellation
- `handlers_aijobs.go`: GET/cancel endpoints for ai_jobs; centralised
cancel registry (moved from handlers_catalogue)
- Batch-covers and chapter-names SSE handlers now create/resume ai_job
records, support from_item/to_item ranges, and resume from items_done
on restart via job_id
- New `POST /api/admin/image-gen/auto-prompt`: generates an image prompt
from book description (cover) or chapter title (chapter) via LLM
- image-gen page: "Auto-prompt" button calls auto-prompt API when a slug
is selected; falls back gracefully if TextGen not configured
- text-gen chapter-names: from/to chapter range inputs + job ID display
- catalogue-tools batch-covers: from/to item range + resume job ID input
- pb-init-v3.sh: adds ai_jobs collection (idempotent)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>