diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts index 9e563c8..c0da47a 100644 --- a/ui/src/lib/audio.svelte.ts +++ b/ui/src/lib/audio.svelte.ts @@ -32,6 +32,8 @@ * It only runs once per chapter (guarded by nextStatus !== 'none'). */ +import type { Voice } from '$lib/types'; + export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error'; export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed'; @@ -50,6 +52,9 @@ class AudioStore { /** Full chapter list for the currently loaded book (number + title). */ chapters = $state<{ number: number; title: string }[]>([]); + /** Available voices (populated by the chapter AudioPlayer on mount). */ + voices = $state([]); + // ── Loading/generation state ──────────────────────────────────────────── status = $state('idle'); audioUrl = $state(''); diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index 8732444..1a67da6 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -226,6 +226,11 @@ audioStore.nextChapter = nextChapter ?? null; }); + // Keep voices in store up to date whenever prop changes. + $effect(() => { + if (voices.length > 0) audioStore.voices = voices; + }); + // Auto-start: if the layout navigated here via auto-next, kick off playback. // We match against the chapter prop so the outgoing chapter's AudioPlayer // (still mounted during the brief navigation window) never reacts to this. @@ -530,6 +535,7 @@ audioStore.bookTitle = bookTitle; audioStore.cover = cover; audioStore.chapters = chapters; + if (voices.length > 0) audioStore.voices = voices; // Update OS media session (lock screen / notification center). setMediaSession(); @@ -1102,72 +1108,7 @@ {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} - - - {#if nextChapter !== null && nextChapter !== undefined} - - {/if} - - - - - - {#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined} -
- {#if audioStore.nextStatus === 'prefetching'} -
- - - - - {m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })} -
- {:else if audioStore.nextStatus === 'prefetched'} -

- - - - {m.reader_ch_ready({ n: String(nextChapter) })} -

- {:else if audioStore.nextStatus === 'failed'} -

{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}

- {/if} -
- {/if} {/if} {:else if audioStore.active} diff --git a/ui/src/lib/components/ListeningMode.svelte b/ui/src/lib/components/ListeningMode.svelte new file mode 100644 index 0000000..63e87a7 --- /dev/null +++ b/ui/src/lib/components/ListeningMode.svelte @@ -0,0 +1,451 @@ + + + + +
+ + {#if audioStore.cover} + + {/if} + + +
+ + Now Playing + + +
+ + + {#if showVoicePanel && voices.length > 0} +
+
+

Select Voice

+ +
+ {#each ([['Kokoro', kokoroVoices], ['Pocket TTS', pocketVoices], ['CF AI', cfaiVoices]] as [string, Voice[]][]) as [label, group]} + {#if group.length > 0} +

{label}

+
+ {#each group as v (v.id)} +
+ + +
+ {/each} +
+ {/if} + {/each} +
+ {/if} + + +
+ + +
+ {#if audioStore.cover} + + {:else} +
+ + + +
+ {/if} + +

+ {audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')} +

+

{audioStore.bookTitle}

+
+ + +
+ +
+ {formatTime(audioStore.currentTime)} + {formatTime(audioStore.duration)} +
+
+ + +
+ + {#if audioStore.chapter > 1 && audioStore.slug} + + + + + + {:else} +
+ {/if} + + + + + + + + + + + + {#if audioStore.nextChapter !== null && audioStore.slug} + + + + + + {:else} +
+ {/if} +
+ + +
+ +
+ {#each SPEED_OPTIONS as s} + + {/each} +
+ + + + + + +
+ + + {#if audioStore.chapters.length > 0} +
+

Chapters

+ +
+ {/if} + +
+
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index afc6e32..77bd52c 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -11,6 +11,7 @@ import { cn } from '$lib/utils'; import * as m from '$lib/paraglide/messages.js'; import { locales, getLocale } from '$lib/paraglide/runtime.js'; + import ListeningMode from '$lib/components/ListeningMode.svelte'; let { children, data }: { children: Snippet; data: LayoutData } = $props(); @@ -34,6 +35,7 @@ // Chapter list drawer state for the mini-player let chapterDrawerOpen = $state(false); let activeChapterEl = $state(null); + let listeningModeOpen = $state(false); function setIfActive(node: HTMLElement, isActive: boolean) { if (isActive) activeChapterEl = node; @@ -287,7 +289,7 @@ audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30); } - const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0]; + const speedSteps = [0.75, 1.0, 1.25, 1.5, 2.0]; function cycleSpeed() { const idx = speedSteps.indexOf(audioStore.speed); @@ -993,6 +995,21 @@ {/if} + + +