diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index a2afb9b..82c428d 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -69,6 +69,8 @@ voices?: Voice[]; /** Called when the server returns 402 (free daily limit reached). */ onProRequired?: () => void; + /** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */ + playerStyle?: 'standard' | 'compact'; } let { @@ -80,7 +82,8 @@ nextChapter = null, chapters = [], voices = [], - onProRequired = undefined + onProRequired = undefined, + playerStyle = 'standard' }: Props = $props(); // ── Derived: voices grouped by engine ────────────────────────────────── @@ -738,6 +741,24 @@ if (m > 0) return `${m}m`; return `${s}s`; } + + // ── Compact player helpers ───────────────────────────────────────────────── + const playPct = $derived( + audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0 + ); + + const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const; + function cycleSpeed() { + const idx = SPEED_OPTIONS.indexOf(audioStore.speed as (typeof SPEED_OPTIONS)[number]); + audioStore.speed = SPEED_OPTIONS[(idx + 1) % SPEED_OPTIONS.length]; + } + + function seekFromCompactBar(e: MouseEvent) { + if (audioStore.duration <= 0) return; + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + audioStore.seekRequest = pct * audioStore.duration; + } @@ -788,6 +809,121 @@ {/snippet} +{#if playerStyle === 'compact'} + +
+ {#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'} +
+ + + + + {m.player_loading()} +
+ + {:else if audioStore.status === 'generating'} +
+
+ {m.reader_generating_narration()} + {Math.round(audioStore.progress)}% +
+
+
+
+
+ + {:else if audioStore.status === 'ready'} +
+ + +
+
+
+ +
+ + + + + + + + + {formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)} + + + +
+
+ {/if} + + {:else if audioStore.active} +
+

+ {m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })} +

+ +
+ + {:else} + + {/if} +
+{:else} +
@@ -1026,3 +1162,4 @@ {/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 114d318..ad5f9a7 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -7,6 +7,7 @@ import CommentsSection from '$lib/components/CommentsSection.svelte'; import type { PageData } from './$types'; import * as m from '$lib/paraglide/messages.js'; + import { audioStore } from '$lib/audio.svelte'; let { data }: { data: PageData } = $props(); @@ -62,6 +63,7 @@ type LineSpacing = 'compact' | 'normal' | 'relaxed'; type ReadWidth = 'narrow' | 'normal' | 'wide'; type ParaStyle = 'spaced' | 'indented'; + type PlayerStyle = 'standard' | 'compact'; interface LayoutPrefs { readMode: ReadMode; @@ -69,12 +71,13 @@ readWidth: ReadWidth; paraStyle: ParaStyle; focusMode: boolean; + playerStyle: PlayerStyle; } const LAYOUT_KEY = 'reader_layout_v1'; const LINE_HEIGHTS: Record = { compact: 1.55, normal: 1.85, relaxed: 2.2 }; const READ_WIDTHS: Record = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' }; - const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false }; + const DEFAULT_LAYOUT: LayoutPrefs = { readMode: 'scroll', lineSpacing: 'normal', readWidth: 'normal', paraStyle: 'spaced', focusMode: false, playerStyle: 'standard' }; function loadLayout(): LayoutPrefs { if (!browser) return DEFAULT_LAYOUT; @@ -92,6 +95,34 @@ if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)); } + // ── Listening settings helpers ─────────────────────────────────────────────── + const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60]; + const sleepSettingsLabel = $derived( + audioStore.sleepAfterChapter + ? 'End Ch.' + : audioStore.sleepUntil > Date.now() + ? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m` + : 'Off' + ); + + function toggleSleepFromSettings() { + if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) { + audioStore.sleepAfterChapter = true; + } else if (audioStore.sleepAfterChapter) { + audioStore.sleepAfterChapter = false; + audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000; + } else { + const remaining = audioStore.sleepUntil - Date.now(); + const currentMin = Math.round(remaining / 60000); + const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin); + if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) { + audioStore.sleepUntil = 0; + } else { + audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000; + } + } + } + // Apply reading CSS vars whenever layout changes $effect(() => { if (!browser) return; @@ -444,6 +475,7 @@ nextChapter={data.next} chapters={data.chapters} voices={data.voices} + playerStyle={layout.playerStyle} onProRequired={() => { audioProRequired = true; }} /> {/if} @@ -735,6 +767,75 @@ {layout.focusMode ? 'On — audio & nav hidden' : 'Off'} + +
+

Listening

+ + +
+

Player style

+
+ {#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, label]} + + {/each} +
+
+ + {#if page.data.user} + +
+

Speed

+
+ {#each [0.75, 1, 1.25, 1.5, 2] as s} + + {/each} +
+
+ + + + + + + {/if} +

Changes save automatically

{/if}