Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b3de3e8d | ||
|
|
5804cd629a | ||
|
|
b130ba4e1b | ||
|
|
cc1f6b87e4 | ||
|
|
8279bd5caa |
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,6 +809,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
if (!isFinite(s) || s <= 0) return '--:--';
|
||||
return formatTime(s);
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -990,7 +1017,7 @@
|
||||
</button>
|
||||
<!-- Time display -->
|
||||
<span class="flex-1 text-xs text-center tabular-nums text-(--color-muted)">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
<!-- Speed cycle -->
|
||||
<button
|
||||
@@ -1024,21 +1051,29 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
||||
<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 gap-2">
|
||||
<svg class="w-4 h-4 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
||||
</svg>
|
||||
<span class="text-sm text-(--color-text) font-medium">{m.reader_audio_narration()}</span>
|
||||
</div>
|
||||
|
||||
<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()}
|
||||
>
|
||||
@@ -1119,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 ── -->
|
||||
|
||||
@@ -1170,7 +1278,7 @@
|
||||
<span>{m.reader_paused()}</span>
|
||||
{/if}
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
onclose: () => void;
|
||||
/** When true, open the chapter picker immediately on mount. */
|
||||
openChapters?: boolean;
|
||||
}
|
||||
|
||||
let { onclose }: Props = $props();
|
||||
let { onclose, openChapters = false }: Props = $props();
|
||||
|
||||
// Voices come from the store (populated by AudioPlayer on mount/play)
|
||||
const voices = $derived(audioStore.voices);
|
||||
@@ -18,6 +20,8 @@
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
let showVoiceModal = $state(false);
|
||||
// svelte-ignore state_referenced_locally
|
||||
let showChapterModal = $state(openChapters && audioStore.chapters.length > 0);
|
||||
let voiceSearch = $state('');
|
||||
let samplePlayingVoice = $state<string | null>(null);
|
||||
let sampleAudio: HTMLAudioElement | null = null;
|
||||
@@ -163,7 +167,8 @@
|
||||
$effect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
|
||||
if (showChapterModal) { showChapterModal = false; }
|
||||
else if (showVoiceModal) { showVoiceModal = false; voiceSearch = ''; }
|
||||
else { onclose(); }
|
||||
}
|
||||
}
|
||||
@@ -200,22 +205,43 @@
|
||||
</svg>
|
||||
</button>
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">Now Playing</span>
|
||||
<!-- Voice selector button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showVoiceModal = !showVoiceModal)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showVoiceModal
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Chapters button -->
|
||||
{#if audioStore.chapters.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = !showChapterModal; showVoiceModal = false; voiceSearch = ''; }}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showChapterModal
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-label="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 -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showVoiceModal = !showVoiceModal; showChapterModal = false; }}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showVoiceModal
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"/>
|
||||
</svg>
|
||||
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Voice modal (full-screen overlay) -->
|
||||
@@ -323,19 +349,92 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="relative flex-1 overflow-y-auto flex flex-col">
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 z-70 flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<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>
|
||||
<!-- Search input -->
|
||||
<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 === audioStore.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 === audioStore.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 === audioStore.chapter ? 'font-semibold text-(--color-brand)' : 'text-(--color-text)'
|
||||
)}>{ch.title || `Chapter ${ch.number}`}</span>
|
||||
<!-- Now-playing indicator -->
|
||||
{#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>
|
||||
{/if}
|
||||
|
||||
<!-- Scrollable body — fills remaining height, content spread vertically -->
|
||||
<div class="relative flex-1 overflow-y-auto flex flex-col justify-between py-4">
|
||||
|
||||
<!-- Cover art + track info -->
|
||||
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
|
||||
<div class="flex flex-col items-center px-8 shrink-0">
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
src={audioStore.cover}
|
||||
alt=""
|
||||
class="w-40 h-56 object-cover rounded-xl shadow-2xl mb-5"
|
||||
class="w-44 h-64 object-cover rounded-xl shadow-2xl mb-5"
|
||||
/>
|
||||
{:else}
|
||||
<div class="w-40 h-56 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
||||
<div class="w-44 h-64 flex items-center justify-center bg-(--color-surface-2) rounded-xl shadow-2xl mb-5 border border-(--color-border)">
|
||||
<svg class="w-16 h-16 text-(--color-muted)/40" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M18 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 14H8v-2h8v2zm0-4H8v-2h8v2zm0-4H8V6h8v2z"/>
|
||||
</svg>
|
||||
@@ -348,212 +447,171 @@
|
||||
<p class="text-sm text-(--color-muted) text-center mt-0.5 truncate max-w-full">{audioStore.bookTitle}</p>
|
||||
</div>
|
||||
|
||||
<!-- Seek bar -->
|
||||
<div class="px-6 shrink-0">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
||||
style="accent-color: var(--color-brand);"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
|
||||
<span>{formatTime(audioStore.currentTime)}</span>
|
||||
<span>{formatTime(audioStore.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Bottom controls cluster: seek + transport + secondary -->
|
||||
<div class="flex flex-col gap-0 px-6 shrink-0">
|
||||
|
||||
<!-- Transport controls -->
|
||||
<div class="flex items-center justify-center gap-4 px-6 pt-5 pb-2 shrink-0">
|
||||
<!-- Prev chapter -->
|
||||
{#if audioStore.chapter > 1 && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipBack}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip back 15 seconds"
|
||||
title="Back 15s"
|
||||
>
|
||||
<svg class="w-6 h-6" 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"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play / Pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePlay}
|
||||
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{#if audioStore.nextChapter !== null && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
||||
<div class="flex items-center justify-center gap-3 px-6 py-3 shrink-0">
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
||||
{#each SPEED_OPTIONS as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.speed = s)}
|
||||
class={cn(
|
||||
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
|
||||
audioStore.speed === s
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.autoNext
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSleepTimer}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
title="Sleep timer"
|
||||
>
|
||||
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{sleepLabel}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chapter list -->
|
||||
{#if audioStore.chapters.length > 0}
|
||||
<div class="mx-4 mb-6 bg-(--color-surface-2) rounded-xl border border-(--color-border) overflow-hidden shrink-0">
|
||||
<!-- Header + search -->
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-(--color-border)">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider shrink-0">Chapters</p>
|
||||
{#if audioStore.chapters.length > 6}
|
||||
<div class="relative flex-1">
|
||||
<svg class="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 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…"
|
||||
bind:value={chapterSearch}
|
||||
class="w-full pl-6 pr-2 py-0.5 text-xs bg-(--color-surface-3) border border-(--color-border) rounded-md text-(--color-text) placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand) transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Seek bar -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="range"
|
||||
aria-label="Seek"
|
||||
min="0"
|
||||
max={audioStore.duration || 0}
|
||||
value={audioStore.currentTime}
|
||||
oninput={seek}
|
||||
class="w-full h-1.5 accent-[--color-brand] cursor-pointer block"
|
||||
style="accent-color: var(--color-brand);"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-(--color-muted) tabular-nums mt-1">
|
||||
<span>{formatTime(audioStore.currentTime)}</span>
|
||||
<span>{formatTime(audioStore.duration)}</span>
|
||||
</div>
|
||||
<div class="overflow-y-auto max-h-64">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
</div>
|
||||
|
||||
<!-- Transport controls -->
|
||||
<div class="flex items-center justify-center gap-4 pt-5 pb-3 shrink-0">
|
||||
<!-- Prev chapter -->
|
||||
{#if audioStore.chapter > 1 && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.chapter - 1}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Previous chapter"
|
||||
aria-label="Previous chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
|
||||
<!-- Skip back 15s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipBack}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip back 15 seconds"
|
||||
title="Back 15s"
|
||||
>
|
||||
<svg class="w-6 h-6" 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"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">15</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Play / Pause -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={togglePlay}
|
||||
class="w-16 h-16 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) transition-colors shadow-lg"
|
||||
aria-label={audioStore.isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
{#if audioStore.isPlaying}
|
||||
<svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-7 h-7 ml-1" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Skip forward 30s -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={skipForward}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Skip forward 30 seconds"
|
||||
title="Forward 30s"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 5V1l5 5-5 5V7c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6h2c0 4.42-3.58 8-8 8s-8-3.58-8-8 3.58-8 8-8z"/>
|
||||
<text x="8.5" y="14.5" font-size="5" font-family="sans-serif" font-weight="bold" fill="currentColor">30</text>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Next chapter -->
|
||||
{#if audioStore.nextChapter !== null && audioStore.slug}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{audioStore.nextChapter}"
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
title="Next chapter"
|
||||
aria-label="Next chapter"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<div class="w-9 h-9"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary controls: Speed · Auto-next · Sleep -->
|
||||
<div class="flex items-center justify-center gap-3 pb-3 shrink-0 flex-wrap">
|
||||
<!-- Speed -->
|
||||
<div class="flex items-center gap-1 bg-(--color-surface-2) rounded-full px-2 py-1 border border-(--color-border)">
|
||||
{#each SPEED_OPTIONS as s}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => playChapter(ch.number)}
|
||||
onclick={() => (audioStore.speed = s)}
|
||||
class={cn(
|
||||
'w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3) text-left',
|
||||
ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'
|
||||
'px-2 py-0.5 rounded-full text-xs font-semibold transition-colors',
|
||||
audioStore.speed === s
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
>
|
||||
<span class="tabular-nums w-7 shrink-0 text-right opacity-50">{ch.number}</span>
|
||||
<span class="flex-1 truncate">{ch.title || `Chapter ${ch.number}`}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
aria-pressed={audioStore.speed === s}
|
||||
>{s}×</button>
|
||||
{/each}
|
||||
{#if filteredChapters.length === 0}
|
||||
<p class="px-4 py-4 text-xs text-(--color-muted) text-center">No chapters match "{chapterSearch}"</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Auto-next -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.autoNext
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
Auto
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycleSleepTimer}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
audioStore.sleepUntil || audioStore.sleepAfterChapter
|
||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||
)}
|
||||
title="Sleep timer"
|
||||
>
|
||||
<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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
{sleepLabel}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,28 +33,13 @@
|
||||
];
|
||||
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
let listeningModeOpen = $state(false);
|
||||
let listeningModeChapters = $state(false);
|
||||
|
||||
// Build time formatted in the user's local timezone (populated on mount so
|
||||
// SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in).
|
||||
let buildTimeLocal = $state('');
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
return {
|
||||
update(nowActive: boolean) { if (nowActive) activeChapterEl = node; },
|
||||
destroy() { if (activeChapterEl === node) activeChapterEl = null; }
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chapterDrawerOpen && activeChapterEl) {
|
||||
activeChapterEl.scrollIntoView({ block: 'center' });
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (env.PUBLIC_BUILD_TIME && env.PUBLIC_BUILD_TIME !== 'unknown') {
|
||||
buildTimeLocal = new Date(env.PUBLIC_BUILD_TIME).toLocaleString();
|
||||
@@ -268,6 +253,11 @@
|
||||
}, 2000) as unknown as number;
|
||||
}
|
||||
|
||||
function formatDuration(s: number): string {
|
||||
if (!isFinite(s) || s <= 0) return '--:--';
|
||||
return formatTime(s);
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -787,52 +777,6 @@
|
||||
{#if audioStore.active}
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface) border-t border-(--color-border) shadow-2xl">
|
||||
|
||||
<!-- Chapter list drawer (slides up above the mini-bar) -->
|
||||
{#if chapterDrawerOpen && audioStore.chapters.length > 0}
|
||||
<div class="border-b border-(--color-border) bg-(--color-surface) flex justify-center md:justify-end md:pr-4">
|
||||
<div class="w-full md:w-80 flex flex-col max-h-72">
|
||||
<!-- Sticky header -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-(--color-border) shrink-0">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.player_chapters()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
aria-label={m.player_close_chapter_list()}
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
>
|
||||
<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="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<!-- Scrollable list -->
|
||||
<div class="overflow-y-auto px-4">
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
use:setIfActive={ch.number === audioStore.chapter}
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={() => (chapterDrawerOpen = false)}
|
||||
class="flex items-center gap-2 py-2 text-xs transition-colors hover:text-(--color-text) {ch.number === audioStore.chapter
|
||||
? 'text-(--color-brand) font-semibold'
|
||||
: 'text-(--color-muted)'}"
|
||||
>
|
||||
<span class="tabular-nums text-(--color-muted) opacity-60 w-8 shrink-0 text-right">
|
||||
{ch.number}
|
||||
</span>
|
||||
<span class="truncate">{ch.title || m.player_chapter_n({ n: String(ch.number) })}</span>
|
||||
{#if ch.number === audioStore.chapter}
|
||||
<svg class="w-3 h-3 shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Generation progress bar (sits at very top of the bar) -->
|
||||
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
||||
<div class="h-0.5 bg-(--color-surface-2)">
|
||||
@@ -859,10 +803,10 @@
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 py-2 flex items-center gap-3">
|
||||
|
||||
<!-- Track info (click to open chapter list drawer) -->
|
||||
<!-- Track info (click to open chapter list in listening mode) -->
|
||||
<button
|
||||
class="flex-1 min-w-0 text-left rounded px-1 -ml-1 hover:bg-(--color-surface-2) transition-colors"
|
||||
onclick={() => { if (audioStore.chapters.length > 0) chapterDrawerOpen = !chapterDrawerOpen; }}
|
||||
onclick={() => { if (audioStore.chapters.length > 0) { listeningModeChapters = true; listeningModeOpen = true; } }}
|
||||
aria-label={audioStore.chapters.length > 0 ? m.player_toggle_chapter_list() : undefined}
|
||||
title={audioStore.chapters.length > 0 ? m.player_chapter_list_label() : undefined}
|
||||
>
|
||||
@@ -872,7 +816,7 @@
|
||||
</p>
|
||||
{:else if audioStore.status === 'ready'}
|
||||
<p class="text-xs text-(--color-muted) tabular-nums leading-tight flex items-center gap-1.5">
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
||||
{#if audioStore.isPreview}
|
||||
<span class="px-1 py-0.5 rounded text-[10px] font-medium bg-(--color-brand)/15 text-(--color-brand) leading-none">preview</span>
|
||||
{/if}
|
||||
@@ -965,7 +909,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (listeningModeOpen = true)}
|
||||
onclick={() => { listeningModeChapters = false; listeningModeOpen = true; }}
|
||||
title="Listening mode"
|
||||
aria-label="Open listening mode"
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
@@ -991,9 +935,13 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening mode full-screen overlay -->
|
||||
{#if listeningModeOpen}
|
||||
<ListeningMode onclose={() => (listeningModeOpen = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Listening mode — mounted at root level, independent of audioStore.active,
|
||||
so closing/pausing audio never tears it down and loses context. -->
|
||||
{#if listeningModeOpen}
|
||||
<ListeningMode
|
||||
onclose={() => { listeningModeOpen = false; listeningModeChapters = false; }}
|
||||
openChapters={listeningModeChapters}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -139,10 +139,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard nav for paginated mode
|
||||
// Keyboard nav for paginated mode + Escape to close settings overlay
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && settingsPanelOpen) {
|
||||
settingsPanelOpen = false;
|
||||
return;
|
||||
}
|
||||
if (layout.readMode !== 'paginated') return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'PageDown' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
@@ -287,6 +291,15 @@
|
||||
html ? (html.replace(/<[^>]*>/g, '').match(/\S+/g)?.length ?? 0) : 0
|
||||
);
|
||||
|
||||
// Strip scraper artifacts from chapter titles:
|
||||
// - Leading digit(s) prefixed before "Chapter" (e.g. "6Chapter 6 : ...")
|
||||
// - Everything after the first newline (often includes a scraped date)
|
||||
const cleanTitle = $derived.by(() => {
|
||||
let t = (data.chapter.title || '').split('\n')[0].trim();
|
||||
t = t.replace(/^\d+(?=Chapter)/i, '').trim();
|
||||
return t || `Chapter ${data.chapter.number}`;
|
||||
});
|
||||
|
||||
// Audio panel: auto-open if this chapter is already loaded/playing in the store
|
||||
// svelte-ignore state_referenced_locally
|
||||
let audioExpanded = $state(
|
||||
@@ -301,7 +314,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })} — {data.book.title} — libnovel</title>
|
||||
<title>{cleanTitle} — {data.book.title} — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Reading progress bar (scroll mode, fixed at top of viewport) -->
|
||||
@@ -364,8 +377,11 @@
|
||||
|
||||
<!-- Chapter heading + meta + language switcher -->
|
||||
<div class="mb-6">
|
||||
<p class="text-xs font-semibold text-(--color-brand) uppercase tracking-widest mb-1.5">
|
||||
{m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
</p>
|
||||
<h1 class="text-xl font-bold text-(--color-text) leading-snug">
|
||||
{data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
{cleanTitle}
|
||||
</h1>
|
||||
<div class="flex items-center flex-wrap gap-x-3 gap-y-1.5 mt-2">
|
||||
{#if wordCount > 0}
|
||||
@@ -375,6 +391,9 @@
|
||||
~{Math.max(1, Math.round(wordCount / 200))} min
|
||||
</p>
|
||||
{/if}
|
||||
{#if data.chapter.date_label}
|
||||
<span class="text-(--color-muted) text-xs opacity-60">{data.chapter.date_label}</span>
|
||||
{/if}
|
||||
|
||||
<!-- Language switcher (inline, compact) -->
|
||||
{#if !data.isPreview}
|
||||
@@ -483,7 +502,7 @@
|
||||
<AudioPlayer
|
||||
slug={data.book.slug}
|
||||
chapter={data.chapter.number}
|
||||
chapterTitle={data.chapter.title || m.reader_chapter_n({ n: String(data.chapter.number) })}
|
||||
chapterTitle={cleanTitle}
|
||||
bookTitle={data.book.title}
|
||||
cover={data.book.cover}
|
||||
nextChapter={data.next}
|
||||
@@ -654,28 +673,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Reader settings bottom sheet ─────────────────────────────────────── -->
|
||||
<!-- ── Reader settings overlay ───────────────────────────────────────────── -->
|
||||
{#if settingsCtx}
|
||||
{#if settingsPanelOpen}
|
||||
<!-- Backdrop -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||
<div role="none" class="fixed inset-0 z-40 bg-black/40" onclick={() => (settingsPanelOpen = false)}></div>
|
||||
|
||||
<div class="fixed bottom-0 left-0 right-0 z-50 bg-(--color-surface-2) border-t border-(--color-border) rounded-t-2xl shadow-2xl flex flex-col max-h-[80dvh]">
|
||||
|
||||
<!-- Drag handle -->
|
||||
<div class="flex justify-center pt-3 pb-1 shrink-0">
|
||||
<div class="w-10 h-1 rounded-full bg-(--color-border)"></div>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 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={() => (settingsPanelOpen = false)}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<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">Reader Settings</span>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div class="flex gap-1 mx-4 mb-3 p-1 rounded-xl bg-(--color-surface-3) shrink-0">
|
||||
<div class="flex gap-1 mx-4 mt-3 mb-1 p-1 rounded-xl bg-(--color-surface-2) shrink-0 border border-(--color-border)">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (settingsTab = 'reading')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'reading'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Reading</button>
|
||||
<button
|
||||
@@ -683,13 +711,13 @@
|
||||
onclick={() => (settingsTab = 'listening')}
|
||||
class="flex-1 py-1.5 rounded-lg text-xs font-semibold transition-colors
|
||||
{settingsTab === 'listening'
|
||||
? 'bg-(--color-surface-2) text-(--color-text) shadow-sm'
|
||||
? 'bg-(--color-brand) text-(--color-surface)'
|
||||
: 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Listening</button>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable content -->
|
||||
<div class="overflow-y-auto px-4 pb-6 flex flex-col gap-0">
|
||||
<div class="flex-1 overflow-y-auto px-4 pb-6 flex flex-col gap-0 mt-2">
|
||||
|
||||
{#if settingsTab === 'reading'}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user