feat: listening settings panel + compact player style
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / runner (push) Successful in 2m53s
Release / Docker / ui (push) Successful in 2m30s
Release / Gitea Release (push) Successful in 49s
All checks were successful
Release / Test backend (push) Successful in 39s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 48s
Release / Docker / backend (push) Successful in 3m16s
Release / Docker / runner (push) Successful in 2m53s
Release / Docker / ui (push) Successful in 2m30s
Release / Gitea Release (push) Successful in 49s
- AudioPlayer: add 'compact' playerStyle prop — slim seekable player with progress bar, skip ±15/30s, play/pause circle button, and speed cycle - Chapter reader settings gear panel: new Listening section with player style picker (Standard/Compact), speed control (0.75–2×), auto-next toggle, and sleep timer — all persisted in reader_layout_v1 localStorage - audioStore.speed/autoNext/sleep now accessible directly from settings panel without opening the audio player Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,8 @@
|
|||||||
voices?: Voice[];
|
voices?: Voice[];
|
||||||
/** Called when the server returns 402 (free daily limit reached). */
|
/** Called when the server returns 402 (free daily limit reached). */
|
||||||
onProRequired?: () => void;
|
onProRequired?: () => void;
|
||||||
|
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */
|
||||||
|
playerStyle?: 'standard' | 'compact';
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -80,7 +82,8 @@
|
|||||||
nextChapter = null,
|
nextChapter = null,
|
||||||
chapters = [],
|
chapters = [],
|
||||||
voices = [],
|
voices = [],
|
||||||
onProRequired = undefined
|
onProRequired = undefined,
|
||||||
|
playerStyle = 'standard'
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ── Derived: voices grouped by engine ──────────────────────────────────
|
// ── Derived: voices grouped by engine ──────────────────────────────────
|
||||||
@@ -738,6 +741,24 @@
|
|||||||
if (m > 0) return `${m}m`;
|
if (m > 0) return `${m}m`;
|
||||||
return `${s}s`;
|
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;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeyDown} />
|
<svelte:window onkeydown={handleKeyDown} />
|
||||||
@@ -788,6 +809,121 @@
|
|||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
|
{#if playerStyle === 'compact'}
|
||||||
|
<!-- ── Compact player ──────────────────────────────────────────────────────── -->
|
||||||
|
<div class="mt-4 p-3 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||||
|
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||||
|
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||||
|
{#if audioStore.status === 'error'}
|
||||||
|
<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||||
|
{/if}
|
||||||
|
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
{m.reader_play_narration()}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{:else if audioStore.status === 'loading'}
|
||||||
|
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||||
|
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
{m.player_loading()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if audioStore.status === 'generating'}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between text-xs text-(--color-muted)">
|
||||||
|
<span>{m.reader_generating_narration()}</span>
|
||||||
|
<span class="tabular-nums">{Math.round(audioStore.progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-1 bg-(--color-surface-3) rounded-full overflow-hidden">
|
||||||
|
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {audioStore.progress}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if audioStore.status === 'ready'}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Seekable progress bar -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden cursor-pointer group"
|
||||||
|
onclick={seekFromCompactBar}
|
||||||
|
>
|
||||||
|
<div class="h-full bg-(--color-brand) rounded-full transition-none" style="width: {playPct}%"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Controls row -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Skip back 15s -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
||||||
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
|
||||||
|
title="-15s"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Play/pause -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { audioStore.toggleRequest++; }}
|
||||||
|
class="w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors flex-shrink-0"
|
||||||
|
>
|
||||||
|
{#if audioStore.isPlaying}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<!-- Skip forward 30s -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
||||||
|
class="text-(--color-muted) hover:text-(--color-text) transition-colors flex-shrink-0"
|
||||||
|
title="+30s"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<!-- Time display -->
|
||||||
|
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
|
||||||
|
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||||
|
</span>
|
||||||
|
<!-- Speed cycle -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={cycleSpeed}
|
||||||
|
class="text-xs font-medium text-(--color-muted) hover:text-(--color-text) flex-shrink-0 tabular-nums transition-colors"
|
||||||
|
title="Playback speed"
|
||||||
|
>
|
||||||
|
{audioStore.speed}×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else if audioStore.active}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-xs text-(--color-muted)">
|
||||||
|
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
|
||||||
|
</p>
|
||||||
|
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
||||||
|
{m.reader_load_this_chapter()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
{m.reader_play_narration()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
||||||
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
<div class="mt-6 p-4 rounded-lg bg-(--color-surface-2) border border-(--color-border)">
|
||||||
<div class="flex items-center justify-between gap-2 mb-3">
|
<div class="flex items-center justify-between gap-2 mb-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -1026,3 +1162,4 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
import CommentsSection from '$lib/components/CommentsSection.svelte';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
type LineSpacing = 'compact' | 'normal' | 'relaxed';
|
type LineSpacing = 'compact' | 'normal' | 'relaxed';
|
||||||
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
type ReadWidth = 'narrow' | 'normal' | 'wide';
|
||||||
type ParaStyle = 'spaced' | 'indented';
|
type ParaStyle = 'spaced' | 'indented';
|
||||||
|
type PlayerStyle = 'standard' | 'compact';
|
||||||
|
|
||||||
interface LayoutPrefs {
|
interface LayoutPrefs {
|
||||||
readMode: ReadMode;
|
readMode: ReadMode;
|
||||||
@@ -69,12 +71,13 @@
|
|||||||
readWidth: ReadWidth;
|
readWidth: ReadWidth;
|
||||||
paraStyle: ParaStyle;
|
paraStyle: ParaStyle;
|
||||||
focusMode: boolean;
|
focusMode: boolean;
|
||||||
|
playerStyle: PlayerStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LAYOUT_KEY = 'reader_layout_v1';
|
const LAYOUT_KEY = 'reader_layout_v1';
|
||||||
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
const LINE_HEIGHTS: Record<LineSpacing, number> = { compact: 1.55, normal: 1.85, relaxed: 2.2 };
|
||||||
const READ_WIDTHS: Record<ReadWidth, string> = { narrow: '58ch', normal: '72ch', wide: 'min(90ch, 100%)' };
|
const READ_WIDTHS: Record<ReadWidth, string> = { 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 {
|
function loadLayout(): LayoutPrefs {
|
||||||
if (!browser) return DEFAULT_LAYOUT;
|
if (!browser) return DEFAULT_LAYOUT;
|
||||||
@@ -92,6 +95,34 @@
|
|||||||
if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout));
|
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
|
// Apply reading CSS vars whenever layout changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -444,6 +475,7 @@
|
|||||||
nextChapter={data.next}
|
nextChapter={data.next}
|
||||||
chapters={data.chapters}
|
chapters={data.chapters}
|
||||||
voices={data.voices}
|
voices={data.voices}
|
||||||
|
playerStyle={layout.playerStyle}
|
||||||
onProRequired={() => { audioProRequired = true; }}
|
onProRequired={() => { audioProRequired = true; }}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -735,6 +767,75 @@
|
|||||||
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
<span class="opacity-60 text-xs">{layout.focusMode ? 'On — audio & nav hidden' : 'Off'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- ── Listening section ─────────────────────────────────────────── -->
|
||||||
|
<div class="border-t border-(--color-border)"></div>
|
||||||
|
<p class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Listening</p>
|
||||||
|
|
||||||
|
<!-- Player style -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs text-(--color-muted)">Player style</p>
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
{#each ([['standard', 'Standard'], ['compact', 'Compact']] as const) as [s, label]}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => setLayout('playerStyle', s)}
|
||||||
|
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||||
|
{layout.playerStyle === s
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||||
|
aria-pressed={layout.playerStyle === s}
|
||||||
|
>{label}</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if page.data.user}
|
||||||
|
<!-- Playback speed -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-xs text-(--color-muted)">Speed</p>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
{#each [0.75, 1, 1.25, 1.5, 2] as s}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { audioStore.speed = s; }}
|
||||||
|
class="flex-1 py-1.5 rounded-lg border text-xs font-medium transition-colors
|
||||||
|
{audioStore.speed === s
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||||
|
aria-pressed={audioStore.speed === s}
|
||||||
|
>{s}×</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-next -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { audioStore.autoNext = !audioStore.autoNext; }}
|
||||||
|
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||||
|
{audioStore.autoNext
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||||
|
aria-pressed={audioStore.autoNext}
|
||||||
|
>
|
||||||
|
<span>Auto-next chapter</span>
|
||||||
|
<span class="opacity-60">{audioStore.autoNext ? 'On' : 'Off'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Sleep timer -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={toggleSleepFromSettings}
|
||||||
|
class="w-full flex items-center justify-between py-2 px-3 rounded-lg border text-xs font-medium transition-colors
|
||||||
|
{audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-muted) hover:border-(--color-brand)/50 hover:text-(--color-text)'}"
|
||||||
|
>
|
||||||
|
<span>Sleep timer</span>
|
||||||
|
<span class="opacity-60">{sleepSettingsLabel}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
|
<p class="text-xs text-(--color-muted)/60 text-center">Changes save automatically</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user