feat(player): refactor chapter picker to full-screen overlay like voice picker
All checks were successful
Release / Test backend (push) Successful in 41s
Release / Check ui (push) Successful in 1m41s
Release / Docker / caddy (push) Successful in 50s
Release / Docker / backend (push) Successful in 2m37s
Release / Docker / runner (push) Successful in 2m36s
Release / Upload source maps (push) Successful in 1m32s
Release / Docker / ui (push) Successful in 2m18s
Release / Gitea Release (push) Successful in 40s

Both ListeningMode and the standard AudioPlayer now open chapters via a
full-screen overlay (same UX as the voice selector) — header + search bar +
rows with circular chapter-number badge, title, and active indicator.
Removes the cramped inline card from the bottom of ListeningMode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Admin
2026-04-05 23:42:49 +05:00
parent cc1f6b87e4
commit b130ba4e1b
2 changed files with 227 additions and 67 deletions

View File

@@ -49,6 +49,7 @@
*/
import { audioStore } from '$lib/audio.svelte';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils';
import type { Voice } from '$lib/types';
@@ -98,6 +99,27 @@
/** Currently active sample <audio> element — one at a time. */
let sampleAudio = $state<HTMLAudioElement | null>(null);
// ── Chapter picker state ─────────────────────────────────────────────────
let showChapterPanel = $state(false);
let chapterSearch = $state('');
const filteredChapters = $derived(
chapterSearch.trim() === ''
? audioStore.chapters
: audioStore.chapters.filter((ch) =>
(ch.title || `Chapter ${ch.number}`)
.toLowerCase()
.includes(chapterSearch.toLowerCase()) ||
String(ch.number).includes(chapterSearch)
)
);
function playChapter(chapterNumber: number) {
audioStore.autoStartChapter = chapterNumber;
showChapterPanel = false;
chapterSearch = '';
goto(`/books/${slug}/chapters/${chapterNumber}`);
}
/**
* Human-readable label for a voice.
* Kokoro: "af_bella" → "Bella (US F)"
@@ -256,11 +278,11 @@
}
});
// Close voice panel when user clicks outside (escape key).
// Close panels on Escape.
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
stopSample();
showVoicePanel = false;
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
else { stopSample(); showVoicePanel = false; }
}
}
@@ -1031,12 +1053,27 @@
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
<div class="p-4">
<div class="flex items-center justify-end gap-2 mb-3">
<!-- Chapter picker button -->
{#if audioStore.chapters.length > 0}
<Button
variant="ghost"
size="sm"
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
class={cn('gap-1.5 text-xs', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title="Browse chapters"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/>
</svg>
Chapters
</Button>
{/if}
<!-- Voice selector button -->
{#if voices.length > 0}
<Button
variant="ghost"
size="sm"
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
class={cn('gap-1.5 text-xs', showVoicePanel ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : '')}
title={m.reader_change_voice()}
>
@@ -1117,6 +1154,79 @@
</div>
{/if}
<!-- ── Chapter picker overlay ────────────────────────────────────────── -->
{#if showChapterPanel && audioStore.chapters.length > 0}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-[60] flex flex-col"
style="background: var(--color-surface);"
>
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
<button
type="button"
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
aria-label="Close chapter picker"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider flex-1">Chapters</span>
</div>
<!-- Search -->
<div class="px-4 py-3 shrink-0 border-b border-(--color-border)">
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="search"
placeholder="Search chapters…"
bind:value={chapterSearch}
class="w-full pl-9 pr-4 py-2 text-sm bg-(--color-surface-2) border border-(--color-border) rounded-lg text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
/>
</div>
</div>
<!-- Chapter list -->
<div class="flex-1 overflow-y-auto">
{#each filteredChapters as ch (ch.number)}
<button
type="button"
onclick={() => playChapter(ch.number)}
class={cn(
'w-full flex items-center gap-3 px-4 py-3 border-b border-(--color-border)/40 transition-colors text-left',
ch.number === chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
)}
>
<!-- Chapter number badge (mirrors voice radio indicator) -->
<span class={cn(
'w-8 h-8 shrink-0 rounded-full border-2 flex items-center justify-center tabular-nums text-xs font-semibold transition-colors',
ch.number === chapter
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
: 'border-(--color-border) text-(--color-muted)'
)}>{ch.number}</span>
<!-- Title -->
<span class={cn(
'flex-1 text-sm truncate',
ch.number === chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
)}>{ch.title || `Chapter ${ch.number}`}</span>
<!-- Now-playing indicator -->
{#if ch.number === chapter}
<svg class="w-4 h-4 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
{/if}
</button>
{/each}
{#if filteredChapters.length === 0}
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
{/if}
</div>
</div>
{/if}
{#if audioStore.isCurrentChapter(slug, chapter)}
<!-- ── This chapter is the active one ── -->