The boolean flag was set by onended before goto() resolved, causing the still-mounted outgoing chapter's AudioPlayer $effect to fire and call startPlayback() for the wrong (old) chapter — restarting it from scratch. Replace with autoStartChapter (number | null): the AudioPlayer only acts when its own chapter prop === autoStartChapter, so the outgoing component never matches and the incoming one fires exactly once on mount.
141 lines
5.7 KiB
TypeScript
141 lines
5.7 KiB
TypeScript
/**
|
||
* Global audio player state for libnovel.
|
||
*
|
||
* A single shared instance (module singleton) keeps audio playing across
|
||
* SvelteKit navigations. The layout mounts the <audio> element once and
|
||
* never unmounts it; the per-chapter AudioPlayer component is just a
|
||
* controller that reads/writes this state.
|
||
*
|
||
* 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 ──────────────────────────────────────────────────────
|
||
slug = $state('');
|
||
chapter = $state(0);
|
||
chapterTitle = $state('');
|
||
bookTitle = $state('');
|
||
voice = $state('af_bella');
|
||
speed = $state(1.0);
|
||
|
||
// ── Loading/generation state ────────────────────────────────────────────
|
||
status = $state<AudioStatus>('idle');
|
||
audioUrl = $state('');
|
||
errorMsg = $state('');
|
||
/** Pseudo-progress bar value 0–100 during generation */
|
||
progress = $state(0);
|
||
|
||
// ── Playback state (kept in sync with the <audio> element) ─────────────
|
||
currentTime = $state(0);
|
||
duration = $state(0);
|
||
isPlaying = $state(false);
|
||
|
||
/**
|
||
* Increment to signal the layout to toggle play/pause.
|
||
* The layout watches this with $effect and calls audioEl.play()/pause().
|
||
*/
|
||
toggleRequest = $state(0);
|
||
|
||
/**
|
||
* Set to a number to seek the audio element to that time (seconds).
|
||
* The layout watches this with $effect and sets audioEl.currentTime.
|
||
* Reset to null after handling.
|
||
*/
|
||
seekRequest = $state<number | null>(null);
|
||
|
||
// ── Auto-next ────────────────────────────────────────────────────────────
|
||
/**
|
||
* When true, navigates to the next chapter when the current one ends
|
||
* and auto-starts its audio.
|
||
*/
|
||
autoNext = $state(false);
|
||
|
||
/**
|
||
* 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<number | null>(null);
|
||
|
||
/**
|
||
* Set to the chapter number that should auto-start by the layout's onended
|
||
* handler (when autoNext fires a navigation). The AudioPlayer on the new
|
||
* page checks this on mount: if it matches the component's own chapter prop
|
||
* it starts playback and clears the value.
|
||
*
|
||
* Using the target chapter number (instead of a plain boolean) prevents the
|
||
* still-mounted outgoing AudioPlayer from reacting to the flag before the
|
||
* navigation completes — it only matches the incoming chapter's component.
|
||
*/
|
||
autoStartChapter = $state<number | null>(null);
|
||
|
||
// ── 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<NextStatus>('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<number | null>(null);
|
||
|
||
/** Whether the mini-bar at the bottom is visible */
|
||
get active(): boolean {
|
||
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
||
}
|
||
|
||
/** True when the currently loaded track matches slug+chapter */
|
||
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();
|