feat(ui): persistent cross-navigation audio player with expanded controls
- Add audio.svelte.ts: module singleton AudioStore (Svelte 5 runes) with
slug/chapter/title metadata, status, progress, playback state, and
toggleRequest/seekRequest signals for layout<->audio element communication
- Rewrite AudioPlayer.svelte as a store controller: no <audio> element owned;
drives audioStore for presign->generate->play flow; shows inline controls
when current chapter is active, 'Now playing / Load this chapter' banner
when a different chapter is playing
- Update +layout.svelte: single persistent <audio> outside {#key} block so
it never unmounts on navigation; effects to load URL, sync speed, handle
toggle/seek requests; fixed bottom mini-player bar with seek, skip 15s/30s,
speed cycle, go-to-chapter link, dismiss; pb-24 padding when active
- Pass chapterTitle and bookTitle from chapter page to AudioPlayer
This commit is contained in:
60
ui/src/lib/audio.svelte.ts
Normal file
60
ui/src/lib/audio.svelte.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||
|
||||
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);
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
|
||||
export const audioStore = new AudioStore();
|
||||
Reference in New Issue
Block a user