diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index f4c471c..81a9dd7 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -72,6 +72,8 @@ onProRequired?: () => void; /** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */ playerStyle?: 'standard' | 'compact'; + /** Approximate word count for the chapter, used to show estimated listen time in the idle state. */ + wordCount?: number; } let { @@ -84,9 +86,13 @@ chapters = [], voices = [], onProRequired = undefined, - playerStyle = 'standard' + playerStyle = 'standard', + wordCount = 0 }: Props = $props(); + /** Estimated listen time in minutes at ~150 wpm average narration speed. */ + const estimatedMinutes = $derived(wordCount > 0 ? Math.max(1, Math.round(wordCount / 150)) : 0); + // ── Derived: voices grouped by engine ────────────────────────────────── const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro')); const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts')); @@ -1063,6 +1069,134 @@ {:else} + +{#if + (!audioStore.isCurrentChapter(slug, chapter) && !audioStore.active) || + (audioStore.isCurrentChapter(slug, chapter) && (audioStore.status === 'idle' || audioStore.status === 'error')) +} + +
+ {#if audioStore.isCurrentChapter(slug, chapter) && audioStore.status === 'error'} +

{audioStore.errorMsg || 'Failed to load audio.'}

+ {/if} +
+ + + + +
+

+ {m.reader_play_narration()} +

+
+ + {#if voices.length > 0} + + {/if} + + {#if estimatedMinutes > 0} + {#if voices.length > 0}·{/if} + ~{estimatedMinutes} min + {/if} +
+
+ + + {#if chapters.length > 0} + + {/if} +
+ + + {#if showVoicePanel && voices.length > 0} +
+
+ {m.reader_choose_voice()} + +
+
+ {#if kokoroVoices.length > 0} +
+ Kokoro (GPU) +
+ {#each kokoroVoices as v (v.id)}{@render voiceRow(v)}{/each} + {/if} + {#if pocketVoices.length > 0} +
+ Pocket TTS (CPU) +
+ {#each pocketVoices as v (v.id)}{@render voiceRow(v)}{/each} + {/if} + {#if cfaiVoices.length > 0} +
+ Cloudflare AI +
+ {#each cfaiVoices as v (v.id)}{@render voiceRow(v)}{/each} + {/if} +
+ +
+ {/if} +
+ +{:else} +
@@ -1167,22 +1301,10 @@ {/if} - {#if audioStore.isCurrentChapter(slug, chapter)} - + - {#if audioStore.status === 'idle' || audioStore.status === 'error'} - {#if audioStore.status === 'error'} -

{audioStore.errorMsg || 'Failed to load audio.'}

- {/if} - - - {:else if audioStore.status === 'loading'} + {#if audioStore.status === 'loading'}
- - {:else} - - {/if}
{/if} diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte index 226ac7d..41b0a28 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -526,6 +526,7 @@ chapters={data.chapters} voices={data.voices} playerStyle={layout.playerStyle} + wordCount={wordCount} onProRequired={() => { audioProRequired = true; }} /> {/if}