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}