diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts index fc74338..0299c74 100644 --- a/ui/src/lib/audio.svelte.ts +++ b/ui/src/lib/audio.svelte.ts @@ -8,9 +8,32 @@ * * Uses Svelte 5 runes ($state / $derived) — import only from .svelte files * or other .svelte.ts files. + * + * ── State machine ──────────────────────────────────────────────────────────── + * + * Current chapter (status): + * idle → loading → ready (fast path: audio exists in MinIO) + * idle → loading → generating → ready (slow path: Kokoro TTS) + * any → error + * + * Next chapter pre-fetch (nextStatus): + * 'none' – no next chapter, or auto-next is off + * 'prefetching' – POST /api/audio running for the next chapter + * 'prefetched' – next chapter audio is ready in MinIO + * 'failed' – pre-generation failed (will retry on navigate) + * + * Auto-next transition: + * onended fires → navigate to next chapter URL + * ↳ new chapter page mounts + * • if nextStatus === 'prefetched' → presign + play immediately + * • else → normal startPlayback() flow + * + * Pre-fetch is triggered when currentTime / duration >= 0.9 (90% mark). + * It only runs once per chapter (guarded by nextStatus !== 'none'). */ export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error'; +export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed'; class AudioStore { // ── What is loaded ────────────────────────────────────────────────────── @@ -56,6 +79,8 @@ class AudioStore { /** * The next chapter number for the currently playing chapter, or null if * there is no next chapter. Written by the chapter page's AudioPlayer. + * Stored here (not cleared on unmount) so onended can still read it after + * the component unmounts due to {#key} re-render on navigation. */ nextChapter = $state(null); @@ -66,6 +91,28 @@ class AudioStore { */ autoStartPending = $state(false); + // ── Next-chapter pre-fetch state ───────────────────────────────────────── + /** + * State of the background pre-generation for the next chapter. + * 'none' – nothing started (default / no next chapter) + * 'prefetching' – currently running POST /api/audio for next chapter + * 'prefetched' – next chapter audio confirmed ready in MinIO + * 'failed' – pre-generation failed (fallback: generate on navigate) + */ + nextStatus = $state('none'); + + /** + * The presigned URL obtained during pre-fetch. When the user navigates + * to the next chapter, AudioPlayer picks this up and skips straight to play. + */ + nextAudioUrl = $state(''); + + /** Progress value (0–100) shown while pre-generating the next chapter. */ + nextProgress = $state(0); + + /** Which chapter number the pre-fetch state above belongs to. */ + nextChapterPrefetched = $state(null); + /** Whether the mini-bar at the bottom is visible */ get active(): boolean { return this.status === 'ready' || this.status === 'generating' || this.status === 'loading'; @@ -75,6 +122,14 @@ class AudioStore { isCurrentChapter(slug: string, chapter: number): boolean { return this.slug === slug && this.chapter === chapter; } + + /** Reset all next-chapter pre-fetch state. */ + resetNextPrefetch() { + this.nextStatus = 'none'; + this.nextAudioUrl = ''; + this.nextProgress = 0; + this.nextChapterPrefetched = null; + } } export const audioStore = new AudioStore(); diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index ac1a244..ac6df9a 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -6,19 +6,27 @@ * which is shared with the layout's persistent