Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
973e639274 |
@@ -54,6 +54,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
slug: string;
|
||||
@@ -107,22 +108,10 @@
|
||||
|
||||
// ── 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}`);
|
||||
}
|
||||
|
||||
@@ -293,7 +282,7 @@
|
||||
// Close panels on Escape.
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showChapterPanel) { showChapterPanel = false; chapterSearch = ''; }
|
||||
if (showChapterPanel) { showChapterPanel = false; }
|
||||
else { stopSample(); showVoicePanel = false; }
|
||||
}
|
||||
}
|
||||
@@ -1055,7 +1044,7 @@
|
||||
{#if voices.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
@@ -1200,7 +1189,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; }}
|
||||
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()}
|
||||
>
|
||||
@@ -1389,75 +1378,13 @@
|
||||
the fixed inset-0 positioning is never clipped by overflow-hidden or
|
||||
border-radius on any ancestor wrapping the AudioPlayer component. -->
|
||||
{#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">
|
||||
<span class="text-sm font-semibold text-(--color-text) flex-1">Chapters</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = false; chapterSearch = ''; }}
|
||||
class="w-9 h-9 flex items-center justify-center 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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</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 -->
|
||||
<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>
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={chapter}
|
||||
zIndex="z-[60]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterPanel = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Float player overlay ──────────────────────────────────────────────────
|
||||
|
||||
139
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
139
ui/src/lib/components/ChapterPickerOverlay.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils';
|
||||
|
||||
interface ChapterMeta {
|
||||
number: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** Full chapter list to render and filter. */
|
||||
chapters: ChapterMeta[];
|
||||
/** Number of the currently-active chapter (highlighted + auto-scrolled). */
|
||||
activeChapter: number;
|
||||
/** z-index class, e.g. "z-[60]" or "z-[80]". Defaults to "z-[60]". */
|
||||
zIndex?: string;
|
||||
/** Called when a chapter row is tapped. The overlay does NOT close itself. */
|
||||
onselect: (chapterNumber: number) => void;
|
||||
/** Called when the close / chevron-down button is tapped. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
chapters,
|
||||
activeChapter,
|
||||
zIndex = 'z-[60]',
|
||||
onselect,
|
||||
onclose
|
||||
}: Props = $props();
|
||||
|
||||
let search = $state('');
|
||||
|
||||
const filtered = $derived(
|
||||
search.trim() === ''
|
||||
? chapters
|
||||
: chapters.filter((ch) =>
|
||||
(ch.title || `Chapter ${ch.number}`)
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase()) ||
|
||||
String(ch.number).includes(search)
|
||||
)
|
||||
);
|
||||
|
||||
/** Scroll the active chapter into view instantly (no animation) when the
|
||||
* list is first rendered so the user never has to hunt for their position. */
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
search = '';
|
||||
onclose();
|
||||
}
|
||||
|
||||
function handleSelect(n: number) {
|
||||
search = '';
|
||||
onselect(n);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 flex flex-col {zIndex}"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0"
|
||||
style="padding-top: max(0.75rem, env(safe-area-inset-top));"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleClose}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close chapter picker"
|
||||
>
|
||||
<!-- chevron-down -->
|
||||
<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={search}
|
||||
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 overscroll-contain"
|
||||
style="padding-bottom: env(safe-area-inset-bottom);"
|
||||
>
|
||||
{#each filtered as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelect(ch.number)}
|
||||
use:scrollIfActive={ch.number === activeChapter}
|
||||
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 === activeChapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<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 === activeChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)'
|
||||
: 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
|
||||
<span class={cn(
|
||||
'flex-1 text-sm truncate',
|
||||
ch.number === activeChapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
|
||||
{#if ch.number === activeChapter}
|
||||
<!-- play icon -->
|
||||
<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 filtered.length === 0}
|
||||
<p class="px-4 py-8 text-sm text-(--color-muted) text-center">No chapters match "{search}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Voice } from '$lib/types';
|
||||
import ChapterPickerOverlay from '$lib/components/ChapterPickerOverlay.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
@@ -92,25 +93,14 @@
|
||||
const filteredCfai = $derived(cfaiVoices.filter((v) => voiceLabel(v).toLowerCase().includes(voiceSearchLower)));
|
||||
|
||||
// ── Chapter search ────────────────────────────────────────────────────────
|
||||
let chapterSearch = $state('');
|
||||
// (search state is managed internally by ChapterPickerOverlay)
|
||||
|
||||
// Scroll the current chapter into view instantly (no animation) when the
|
||||
// chapter modal opens. Applied to every chapter button; only scrolls when
|
||||
// the chapter number matches the currently playing one. Runs once on mount
|
||||
// before the browser paints so no scroll animation is ever visible.
|
||||
function scrollIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) node.scrollIntoView({ block: 'center', behavior: 'instant' });
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
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 voiceLabel(v: Voice | string): string {
|
||||
if (typeof v === 'string') {
|
||||
@@ -156,13 +146,6 @@
|
||||
voiceSearch = '';
|
||||
}
|
||||
|
||||
// ── Chapter click-to-play ─────────────────────────────────────────────────
|
||||
function playChapter(chapterNumber: number) {
|
||||
audioStore.autoStartChapter = chapterNumber;
|
||||
onclose();
|
||||
goto(`/books/${audioStore.slug}/chapters/${chapterNumber}`);
|
||||
}
|
||||
|
||||
// ── Speed ────────────────────────────────────────────────────────────────
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
@@ -453,63 +436,13 @@
|
||||
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[80] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = false; }}
|
||||
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>
|
||||
<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>
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
use:scrollIfActive={ch.number === audioStore.chapter}
|
||||
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 === audioStore.chapter ? 'bg-(--color-brand)/8' : 'hover:bg-(--color-surface-2)'
|
||||
)}
|
||||
>
|
||||
<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 === audioStore.chapter ? 'border-(--color-brand) bg-(--color-brand) text-(--color-surface)' : 'border-(--color-border) text-(--color-muted)'
|
||||
)}>{ch.number}</span>
|
||||
<span class={cn('flex-1 text-sm truncate', ch.number === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)')}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.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>
|
||||
<ChapterPickerOverlay
|
||||
chapters={audioStore.chapters}
|
||||
activeChapter={audioStore.chapter}
|
||||
zIndex="z-[80]"
|
||||
onselect={playChapter}
|
||||
onclose={() => { showChapterModal = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- ── Controls area (bottom half) ───────────────────────────────────── -->
|
||||
|
||||
Reference in New Issue
Block a user