Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3ad54db70 | ||
|
|
48bc206c4e |
@@ -169,6 +169,9 @@ services:
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
PORT: "8000"
|
||||
ENABLE_USER_REGISTRATION: "false"
|
||||
MEDIA_ROOT: "/code/uploads"
|
||||
volumes:
|
||||
- glitchtip_uploads:/code/uploads
|
||||
healthcheck:
|
||||
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"]
|
||||
interval: 15s
|
||||
@@ -190,6 +193,9 @@ services:
|
||||
DEFAULT_FROM_EMAIL: "noreply@libnovel.cc"
|
||||
VALKEY_URL: "redis://valkey:6379/1"
|
||||
SERVER_ROLE: "worker"
|
||||
MEDIA_ROOT: "/code/uploads"
|
||||
volumes:
|
||||
- glitchtip_uploads:/code/uploads
|
||||
|
||||
# ── Umami ───────────────────────────────────────────────────────────────────
|
||||
umami:
|
||||
@@ -540,3 +546,4 @@ volumes:
|
||||
grafana_data:
|
||||
pocket_tts_cache:
|
||||
hf_cache:
|
||||
glitchtip_uploads:
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
* It only runs once per chapter (guarded by nextStatus !== 'none').
|
||||
*/
|
||||
|
||||
import type { Voice } from '$lib/types';
|
||||
|
||||
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
||||
|
||||
@@ -50,6 +52,9 @@ class AudioStore {
|
||||
/** Full chapter list for the currently loaded book (number + title). */
|
||||
chapters = $state<{ number: number; title: string }[]>([]);
|
||||
|
||||
/** Available voices (populated by the chapter AudioPlayer on mount). */
|
||||
voices = $state<Voice[]>([]);
|
||||
|
||||
// ── Loading/generation state ────────────────────────────────────────────
|
||||
status = $state<AudioStatus>('idle');
|
||||
audioUrl = $state('');
|
||||
|
||||
@@ -226,6 +226,11 @@
|
||||
audioStore.nextChapter = nextChapter ?? null;
|
||||
});
|
||||
|
||||
// Keep voices in store up to date whenever prop changes.
|
||||
$effect(() => {
|
||||
if (voices.length > 0) audioStore.voices = voices;
|
||||
});
|
||||
|
||||
// Auto-start: if the layout navigated here via auto-next, kick off playback.
|
||||
// We match against the chapter prop so the outgoing chapter's AudioPlayer
|
||||
// (still mounted during the brief navigation window) never reacts to this.
|
||||
@@ -530,6 +535,7 @@
|
||||
audioStore.bookTitle = bookTitle;
|
||||
audioStore.cover = cover;
|
||||
audioStore.chapters = chapters;
|
||||
if (voices.length > 0) audioStore.voices = voices;
|
||||
|
||||
// Update OS media session (lock screen / notification center).
|
||||
setMediaSession();
|
||||
@@ -1102,72 +1108,7 @@
|
||||
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Auto-next toggle (keep here as useful context) -->
|
||||
{#if nextChapter !== null && nextChapter !== undefined}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1.5 text-xs flex-shrink-0', audioStore.autoNext ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
title={audioStore.autoNext ? m.player_auto_next_on() : m.player_auto_next_off()}
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<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>
|
||||
{m.reader_auto_next()}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<!-- Sleep timer -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class={cn('gap-1 text-xs flex-shrink-0', audioStore.sleepUntil || audioStore.sleepAfterChapter ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted)')}
|
||||
onclick={cycleSleepTimer}
|
||||
title={audioStore.sleepAfterChapter
|
||||
? 'Stop after this chapter'
|
||||
: audioStore.sleepUntil
|
||||
? `Sleep timer: ${formatSleepRemaining(sleepRemainingSec)} remaining`
|
||||
: 'Sleep timer off'}
|
||||
>
|
||||
<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>
|
||||
{#if audioStore.sleepAfterChapter}
|
||||
End Ch.
|
||||
{:else if audioStore.sleepUntil}
|
||||
{formatSleepRemaining(sleepRemainingSec)}
|
||||
{:else}
|
||||
Sleep
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Next chapter pre-fetch status (only when auto-next is on) -->
|
||||
{#if audioStore.autoNext && nextChapter !== null && nextChapter !== undefined}
|
||||
<div class="mt-2">
|
||||
{#if audioStore.nextStatus === 'prefetching'}
|
||||
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
||||
<svg class="w-3 h-3 animate-spin flex-shrink-0" 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>
|
||||
<span>{m.reader_ch_preparing({ n: String(nextChapter), percent: String(Math.round(audioStore.nextProgress)) })}</span>
|
||||
</div>
|
||||
{:else if audioStore.nextStatus === 'prefetched'}
|
||||
<p class="text-xs text-(--color-muted) flex items-center gap-1">
|
||||
<svg class="w-3 h-3 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z"/>
|
||||
</svg>
|
||||
{m.reader_ch_ready({ n: String(nextChapter) })}
|
||||
</p>
|
||||
{:else if audioStore.nextStatus === 'failed'}
|
||||
<p class="text-xs text-(--color-muted) opacity-60">{m.reader_ch_generate_on_nav({ n: String(nextChapter) })}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{:else if audioStore.active}
|
||||
|
||||
451
ui/src/lib/components/ListeningMode.svelte
Normal file
451
ui/src/lib/components/ListeningMode.svelte
Normal file
@@ -0,0 +1,451 @@
|
||||
<script lang="ts">
|
||||
import { audioStore } from '$lib/audio.svelte';
|
||||
import { cn } from '$lib/utils';
|
||||
import type { Voice } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** Called when the user closes the overlay. */
|
||||
onclose: () => void;
|
||||
}
|
||||
|
||||
let { onclose }: Props = $props();
|
||||
|
||||
// Voices come from the store (populated by AudioPlayer on mount/play)
|
||||
const voices = $derived(audioStore.voices);
|
||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
||||
|
||||
let showVoicePanel = $state(false);
|
||||
let samplePlayingVoice = $state<string | null>(null);
|
||||
let sampleAudio: HTMLAudioElement | null = null;
|
||||
|
||||
function voiceLabel(v: Voice | string): string {
|
||||
if (typeof v === 'string') {
|
||||
const found = voices.find((x) => x.id === v);
|
||||
if (found) return voiceLabel(found);
|
||||
const id = v as string;
|
||||
return id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
const base = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return v.lang !== 'en-us' ? `${base} (${v.lang})` : base;
|
||||
}
|
||||
|
||||
function stopSample() {
|
||||
if (sampleAudio) {
|
||||
sampleAudio.pause();
|
||||
sampleAudio.src = '';
|
||||
sampleAudio = null;
|
||||
}
|
||||
samplePlayingVoice = null;
|
||||
}
|
||||
|
||||
async function playSample(voiceId: string) {
|
||||
if (samplePlayingVoice === voiceId) { stopSample(); return; }
|
||||
stopSample();
|
||||
samplePlayingVoice = voiceId;
|
||||
try {
|
||||
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
|
||||
if (!res.ok) { samplePlayingVoice = null; return; }
|
||||
const { url } = (await res.json()) as { url: string };
|
||||
sampleAudio = new Audio(url);
|
||||
sampleAudio.onended = () => stopSample();
|
||||
sampleAudio.onerror = () => stopSample();
|
||||
sampleAudio.play().catch(() => stopSample());
|
||||
} catch {
|
||||
samplePlayingVoice = null;
|
||||
}
|
||||
}
|
||||
|
||||
function selectVoice(voiceId: string) {
|
||||
stopSample();
|
||||
audioStore.voice = voiceId;
|
||||
showVoicePanel = false;
|
||||
}
|
||||
|
||||
// ── Speed ────────────────────────────────────────────────────────────────
|
||||
const SPEED_OPTIONS = [0.75, 1, 1.25, 1.5, 2] as const;
|
||||
|
||||
// ── Sleep timer ──────────────────────────────────────────────────────────
|
||||
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
|
||||
|
||||
let sleepRemainingSec = $derived.by(() => {
|
||||
void audioStore.currentTime; // re-run every second while playing
|
||||
if (!audioStore.sleepUntil) return 0;
|
||||
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
|
||||
});
|
||||
|
||||
function cycleSleepTimer() {
|
||||
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = true;
|
||||
} else if (audioStore.sleepAfterChapter) {
|
||||
audioStore.sleepAfterChapter = false;
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
||||
} else {
|
||||
const remaining = audioStore.sleepUntil - Date.now();
|
||||
const currentMin = Math.round(remaining / 60000);
|
||||
const idx = SLEEP_OPTIONS.findIndex((m) => m >= currentMin);
|
||||
if (idx === -1 || idx === SLEEP_OPTIONS.length - 1) {
|
||||
audioStore.sleepUntil = 0;
|
||||
} else {
|
||||
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[idx + 1] * 60 * 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatSleepRemaining(secs: number): string {
|
||||
if (secs <= 0) return 'Off';
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return m > 0 ? `${m}m${s > 0 ? ` ${s}s` : ''}` : `${s}s`;
|
||||
}
|
||||
|
||||
const sleepLabel = $derived(
|
||||
audioStore.sleepAfterChapter
|
||||
? 'End Ch.'
|
||||
: audioStore.sleepUntil > Date.now()
|
||||
? formatSleepRemaining(sleepRemainingSec)
|
||||
: 'Sleep'
|
||||
);
|
||||
|
||||
// ── Format time ──────────────────────────────────────────────────────────
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = Math.floor(s % 60);
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ── Playback controls ────────────────────────────────────────────────────
|
||||
function seek(e: Event) {
|
||||
audioStore.seekRequest = Number((e.currentTarget as HTMLInputElement).value);
|
||||
}
|
||||
function skipBack() {
|
||||
audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15);
|
||||
}
|
||||
function skipForward() {
|
||||
audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30);
|
||||
}
|
||||
function togglePlay() {
|
||||
audioStore.toggleRequest++;
|
||||
}
|
||||
|
||||
// Close on Escape
|
||||
$effect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (showVoicePanel) { showVoicePanel = false; }
|
||||
else { onclose(); }
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Full-screen listening mode overlay -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-60 flex flex-col overflow-hidden"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Blurred cover background -->
|
||||
{#if audioStore.cover}
|
||||
<div
|
||||
class="absolute inset-0 bg-cover bg-center opacity-10 blur-2xl scale-110"
|
||||
style="background-image: url('{audioStore.cover}');"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Header bar -->
|
||||
<div class="relative flex items-center justify-between px-4 py-3 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onclose}
|
||||
class="p-2 rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors"
|
||||
aria-label="Close listening mode"
|
||||
>
|
||||
<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">Now Playing</span>
|
||||
<!-- Voice selector button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showVoicePanel = !showVoicePanel)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors',
|
||||
showVoicePanel
|
||||
? '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>
|
||||
|
||||
<!-- Voice panel (inline dropdown below header) -->
|
||||
{#if showVoicePanel && voices.length > 0}
|
||||
<div class="relative mx-4 mb-2 bg-(--color-surface-2) border border-(--color-border) rounded-xl p-3 z-10 overflow-y-auto max-h-56 shrink-0">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider">Select Voice</p>
|
||||
<button type="button" onclick={() => { stopSample(); showVoicePanel = false; }} class="text-(--color-muted) hover:text-(--color-text) transition-colors" aria-label="Close voice panel">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{#each ([['Kokoro', kokoroVoices], ['Pocket TTS', pocketVoices], ['CF AI', cfaiVoices]] as [string, Voice[]][]) as [label, group]}
|
||||
{#if group.length > 0}
|
||||
<p class="text-[10px] text-(--color-muted) opacity-60 mb-1 mt-2 first:mt-0">{label}</p>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each group as v (v.id)}
|
||||
<div class="flex items-center rounded-lg border overflow-hidden text-xs
|
||||
{audioStore.voice === v.id
|
||||
? 'border-(--color-brand) bg-(--color-brand)/10'
|
||||
: 'border-(--color-border) bg-(--color-surface-3)'}">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selectVoice(v.id)}
|
||||
class="px-2 py-1 font-medium transition-colors
|
||||
{audioStore.voice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>{voiceLabel(v)}</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
|
||||
class="px-1.5 py-1 border-l border-(--color-border) transition-colors
|
||||
{samplePlayingVoice === v.id ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
title={samplePlayingVoice === v.id ? 'Stop sample' : 'Play sample'}
|
||||
>
|
||||
{#if samplePlayingVoice === v.id}
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Scrollable body -->
|
||||
<div class="relative flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Cover art + track info -->
|
||||
<div class="flex flex-col items-center px-8 pt-4 pb-6 shrink-0">
|
||||
{#if audioStore.cover}
|
||||
<img
|
||||
src={audioStore.cover}
|
||||
alt=""
|
||||
class="w-40 h-56 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)">
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="text-base font-bold text-(--color-text) text-center leading-snug">
|
||||
{audioStore.chapterTitle || (audioStore.chapter > 0 ? `Chapter ${audioStore.chapter}` : '')}
|
||||
</p>
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2.5 border-b border-(--color-border)">Chapters</p>
|
||||
<div class="overflow-y-auto max-h-64">
|
||||
{#each audioStore.chapters as ch (ch.number)}
|
||||
<a
|
||||
href="/books/{audioStore.slug}/chapters/{ch.number}"
|
||||
onclick={onclose}
|
||||
class="flex items-center gap-3 px-4 py-2.5 text-xs transition-colors hover:bg-(--color-surface-3)
|
||||
{ch.number === audioStore.chapter ? 'text-(--color-brand) font-semibold bg-(--color-brand)/5' : 'text-(--color-muted)'}"
|
||||
>
|
||||
<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}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -11,6 +11,7 @@
|
||||
import { cn } from '$lib/utils';
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { locales, getLocale } from '$lib/paraglide/runtime.js';
|
||||
import ListeningMode from '$lib/components/ListeningMode.svelte';
|
||||
|
||||
let { children, data }: { children: Snippet; data: LayoutData } = $props();
|
||||
|
||||
@@ -34,6 +35,7 @@
|
||||
// Chapter list drawer state for the mini-player
|
||||
let chapterDrawerOpen = $state(false);
|
||||
let activeChapterEl = $state<HTMLElement | null>(null);
|
||||
let listeningModeOpen = $state(false);
|
||||
|
||||
function setIfActive(node: HTMLElement, isActive: boolean) {
|
||||
if (isActive) activeChapterEl = node;
|
||||
@@ -287,7 +289,7 @@
|
||||
audioEl.currentTime = Math.min(audioEl.duration || 0, audioEl.currentTime + 30);
|
||||
}
|
||||
|
||||
const speedSteps = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0];
|
||||
const speedSteps = [0.75, 1.0, 1.25, 1.5, 2.0];
|
||||
|
||||
function cycleSpeed() {
|
||||
const idx = speedSteps.indexOf(audioStore.speed);
|
||||
@@ -993,6 +995,21 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!-- Headphones: open listening mode -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onclick={() => (listeningModeOpen = true)}
|
||||
title="Listening mode"
|
||||
aria-label="Open listening mode"
|
||||
class="text-(--color-muted) hover:text-(--color-text) flex-shrink-0"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 18v-6a9 9 0 0118 0v6"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 19a2 2 0 01-2 2h-1a2 2 0 01-2-2v-3a2 2 0 012-2h3zM3 19a2 2 0 002 2h1a2 2 0 002-2v-3a2 2 0 00-2-2H3z"/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
<!-- Dismiss -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -1008,4 +1025,9 @@
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listening mode full-screen overlay -->
|
||||
{#if listeningModeOpen}
|
||||
<ListeningMode onclose={() => (listeningModeOpen = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user