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}