Three compounding issues caused the 4+ second load:
1. getAllRatings() ran sequentially after the first Promise.all group,
adding it unnecessarily to the critical path. Now runs in parallel
with listBooks/getVotedSlugs/getSavedSlugs (all 4 concurrent).
2. discovery_votes was fetched twice on every page load — once inside
getBooksForDiscovery (via getVotedSlugs) and again by getVotedBooks.
Fixed by caching getVotedSlugs results with a 30s TTL so the second
call hits cache instead of PocketBase.
3. getVotedSlugs and getSavedSlugs were always uncached, hitting
PocketBase on every navigation. Added short-TTL per-user Valkey
cache entries (voted: 30s, saved: 60s). Cache is invalidated
immediately after each write (upsertDiscoveryVote, clearDiscoveryVotes,
undoDiscoveryVote, saveBook) so stale data is never served.
Removed the custom clear button (shown when query is non-empty) and
suppressed the browser-native webkit search cancel button via CSS.
Only the single Cancel button remains, avoiding the double/triple X
clutter on wider screens.
The wrapper div in +layout.svelte had pointer-events:none which blocked
all taps inside ListeningMode (chapter rows, buttons, scrolling). Removed
the wrapper div and moved the fly transition onto ListeningMode's own root
element so the slide-in animation works without stealing pointer events.
- Add notify_new_chapters_push field to AppUser, PATCH /api/profile, and profile loader
- Fix bell panel to reload notifications on every open (not just once on mount)
- Replace flat in-app + push toggles with structured category table (Category | In-app | Push)
- Add browser push master subscribe/unsubscribe row above the table
- Push column toggle disabled until browser is subscribed; shows — when unsupported/denied
- Update Notifications row hint to summarise active channels (In-app · Push / Off)
- getReadyChaptersForSlug(slug, preferredVoice) in pocketbase.ts: per-slug done jobs map, cached 60s, prefers user's voice
- GET /api/audio/chapters?slug=&voice= endpoint
- Chapter list (/books/[slug]/chapters): amber headphones icon on ready rows, play button for instant listen, banner showing ready count
- Chapter reader: audioReady + availableVoice from server load; 'Audio ready — Listen now' banner shown when audio exists and player is not yet active; sets voice preference before expanding player
- getBooksWithAudioCount() in pocketbase.ts aggregates done audio_jobs, deduplicates by chapter per slug, caches 5 min
- GET /api/audio/books endpoint
- home page: readyToListen shelf with headphones badge, chapter count, Listen button, hideable
- /listen page: full grid with search, sort (most narrated / A-Z / recent), empty state
Float mode was broken because AudioPlayer was unmounted the moment
audioStore.active became true — exactly when the float overlay needs
to render. Fix: keep AudioPlayer mounted in float and minimal modes
regardless of audioStore.active; only standard mode shows the
'Controls below' message.
Adds a third 'minimal' style: a compact single-row bar (skip ±,
play/pause, seek, time) with no voice picker or chapter browser.
Voice picker and chapter button are hidden in the idle pill too.
Settings UI updated to show all three options with a live
description of what each style does.
- Replace bell dropdown with full-screen NotificationsModal (mirrors SearchModal pattern)
- Notifications visible to all logged-in users (not just admin)
- Admin users excluded from new-chapter fan-out (dedup vs Scrape Complete notification)
- Users with notify_new_chapters=false opted out of new-chapter in-app notifications
- Toggle in profile page to enable/disable in-app new-chapter notifications
- PATCH /api/profile endpoint to save notification preferences
- User-facing /notifications page (admin redirects to /admin/notifications)
Unify the duplicated chapter picker overlays from AudioPlayer and
ListeningMode into a single ChapterPickerOverlay component.
Both callers keep their own onselect handlers; the overlay owns
search state internally and includes safe-area insets + scrollIfActive.
- Replace voice <select> with a two-column card grid grouped by engine
(Kokoro GPU / Pocket TTS CPU / Cloudflare AI); each card has a per-voice
sample play/pause button matching AudioPlayer behaviour
- Add Announce chapter and Audio mode (Stream/Generate) toggles to a
unified Playback row in Preferences; Audio mode toggle disabled for
CF AI voices
- Remove duplicate PUT /api/settings from the profile page; all writes
go directly into audioStore / theme context and the layout's single
debounced effect persists them
- Add Danger Zone section: collapsible, requires typing username to
unlock Delete account button; calls DELETE /api/profile
- Add deleteUserAccount() to pocketbase.ts: purges user_settings,
user_library, progress, comment_votes, book_ratings,
user_subscriptions, notifications, user_sessions then the
app_users record
- Add DELETE /api/profile server route (auth-guarded)
Add a user-selectable playback mode stored in user_settings:
- 'stream' (default): /api/audio-stream starts playing within seconds,
saves to MinIO concurrently — low latency
- 'generate': queue runner task, poll until full audio is ready in
MinIO, then play via presigned URL — legacy behaviour
UI toggles in two places:
- AudioPlayer idle pill: compact '· Stream / · Generate' inline next
to voice name and estimated duration
- ListeningMode controls row: pill alongside Auto, Announce, Sleep;
disabled and grayed out for CF AI voices (batch-only, no streaming)
startPlayback() now branches on audioStore.audioMode for non-CF AI
voices; generate mode uses the same runner task + progress bar flow
as CF AI but without the preview clip.
PocketBase: audio_mode text field added to user_settings on
pb.libnovel.cc (live) and in pb-init-v3.sh (create block +
add_field migration line).
- Import task: persist object_key, author, cover_url, genres, summary,
book_status in PocketBase so the runner can fetch the file and write
book metadata on completion
- Runner poll mode: pass task.ObjectKey instead of empty string
- Runner: write BookMeta + UpsertBook in Meilisearch after chapter ingest
so imported books appear in catalogue and search
- Import UI: add author, cover URL, genres, summary, status fields; add
AI tasks panel (chapter names, description, image gen, tagline) after
import completes; add AI tasks button on each done task in the list
- Admin nav: add Notifications entry to sidebar (all 5 locales)
- Logout: delete user_sessions row on sign-out so sessions don't
accumulate as phantoms after each login/logout cycle
The paraglide .gitignore uses '*' to ignore all generated files.
admin_nav_import.js was never force-added after the key was introduced
in v2.6.44, causing svelte-check to fail in CI with 'Cannot find module'.
The book_comments collection has a custom 'id' field (type: text,
required: true) distinct from PocketBase's system record ID.
Every comment POST was returning 400 validation_required because
the id field was never sent.
Fix: generate a 15-char hex ID via crypto.randomUUID() and include
it in the payload, matching PocketBase's own ID alphabet.
Hand-authored the 12 missing .js message modules and updated _index.js
since npm/paraglide-js codegen cannot run in this environment.
These are equivalent to what 'npm run paraglide' would generate.
Going forward: run 'npm run paraglide' after adding keys to messages/*.json.
- Remove compact player mode (seekable bar inline was undifferentiated
from standard; didn't have voice/chapter/warm-up controls)
- Add 'float' player style: a fixed, draggable mini-overlay positioned
above the bottom mini-bar (bottom-right corner, z-55)
· Seek bar, skip ±15/30s, play/pause, time, speed indicator
· Drag handle (pointer capture) to reposition anywhere on screen
· Animated playing pulse dot in the title row
· Suppresses the standard non-idle inline block when active so
there is no duplicate UI
- PlayerStyle type: 'standard' | 'float' (was 'compact')
- Bump localStorage key to reader_layout_v2 so old 'compact' pref
does not pollute the new default
- Listening tab Style row now shows Standard / Float
- Admin layout: SVG icons, active highlight, divider between nav sections
- Scrape page: status filter pills with counts, text + status combined search
- Audio page: status filter pills, cancel jobs, retry failed jobs, mobile cards for cache tab
- Translation page: status filter pills (incl. cancelled), cancel + retry jobs, mobile cancel/retry cards, i18n for all labels
- AI Jobs page: fix concurrent cancel (Set instead of single slot), per-job cancel errors inline, full mobile card layout, i18n title/heading
- Text-gen page: tagline editable input + copy, warnings copy, i18n title/heading
- Book page: chapter cover Save button, audio monitor link, currentShelf pre-populated from server
- pocketbase.ts: add getBookShelf(), shelf field on UserLibraryEntry
- New API route: POST /api/admin/translation/bulk (proxy for translation retry)
- i18n: 15 new admin_translation_*, admin_ai_jobs_*, admin_text_gen_* keys across all 5 locales
- Listening tab: expose Speed, Auto-next, and Announce chapter controls
alongside the existing Player Style row (chapters/[n]/+page.svelte)
- AudioPlayer: add Warm up Ch. N button in standard player ready state
when autoNext is off; shows prefetching spinner and completion status
- Book page: fetch saved audioTime on mount; show Resume Ch. N button
(desktop + mobile) that sets autoStartChapter and navigates directly
Replace the two-row layout (toolbar + lonely 'Play narration' button)
with a single pill row:
- Large circular play button on the left (brand color, active:scale-95)
- 'Play narration' label + voice selector button + estimated duration
(based on word count at ~150wpm) in the centre
- Chapters icon button on the right
Voice panel drops inline below the pill when open.
Non-idle states (loading / generating / ready / other-chapter-playing)
keep the existing toolbar + status layout unchanged.
Also adds wordCount prop to AudioPlayer and passes it from the chapter page.
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.
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.
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
- 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)
- 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
- 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
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.
- 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
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>