1604 lines
62 KiB
Svelte
1604 lines
62 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* AudioPlayer — controller component.
|
|
*
|
|
* Does NOT own an <audio> element. Instead it reads/writes `audioStore`,
|
|
* which is shared with the layout's persistent <audio> element so audio
|
|
* survives SvelteKit navigations.
|
|
*
|
|
* ── Play flow ────────────────────────────────────────────────────────────
|
|
* On "Play narration" click / auto-start:
|
|
* 1. Populate store metadata (slug, chapter, titles, voice, speed).
|
|
* 2. If the pre-fetch already landed (nextStatus='prefetched' AND
|
|
* nextChapterPrefetched === chapter), use the cached URL immediately.
|
|
* 3. Otherwise try GET /api/presign/audio — if 200, set audioUrl → layout plays.
|
|
* 4. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar.
|
|
* On success (200 or 202→done), presign and set audioUrl directly from MinIO.
|
|
*
|
|
* ── Voice selection ──────────────────────────────────────────────────────
|
|
* A "Change voice" panel lets users pick from the available Kokoro voices.
|
|
* Each voice shows a play button that streams a pre-generated sample from
|
|
* MinIO (GET /api/presign/voice-sample?voice=...). Samples are generated
|
|
* server-side via POST /api/audio/voice-samples.
|
|
*
|
|
* Changing voice updates audioStore.voice (saved to settings via layout).
|
|
* The currently loaded chapter audio is NOT re-generated automatically —
|
|
* the new voice takes effect on next "Play narration" click.
|
|
*
|
|
* ── Pre-fetch (immediate + 90% fallback) ────────────────────────────────
|
|
* When autoNext is on, prefetchNext() is called as soon as the current
|
|
* chapter starts playing (via maybeStartPrefetch() at the end of
|
|
* startPlayback()). This gives the maximum lead time for Kokoro to
|
|
* generate the next chapter so the transition is seamless.
|
|
*
|
|
* A $effect also watches currentTime/duration and fires prefetchNext() at
|
|
* the 90% mark as a fallback — covering the case where autoNext was toggled
|
|
* on mid-playback after startPlayback() had already returned.
|
|
* The nextStatus !== 'none' guard prevents double-runs in all cases.
|
|
*
|
|
* prefetchNext():
|
|
* • Calls POST /api/audio for next chapter (sets nextStatus='prefetching')
|
|
* • On success, presigns and stores URL in audioStore.nextAudioUrl
|
|
* (sets nextStatus='prefetched')
|
|
* • On failure, sets nextStatus='failed'
|
|
*
|
|
* ── Auto-next ────────────────────────────────────────────────────────────
|
|
* layout.svelte onended → sets autoStartPending=true → navigates.
|
|
* New chapter's AudioPlayer mounts → sees autoStartPending → startPlayback()
|
|
* which uses the prefetched URL if available.
|
|
*/
|
|
|
|
import { audioStore } from '$lib/audio.svelte';
|
|
import { goto } from '$app/navigation';
|
|
import { untrack } from 'svelte';
|
|
import { Button } from '$lib/components/ui/button';
|
|
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;
|
|
chapter: number;
|
|
chapterTitle?: string;
|
|
bookTitle?: string;
|
|
/** Cover image URL for the book (used in MediaSession for lock-screen art). */
|
|
cover?: string;
|
|
/** Next chapter number, or null/undefined if this is the last chapter. */
|
|
nextChapter?: number | null;
|
|
/** Full chapter list for the book (number + title). Written into the store. */
|
|
chapters?: { number: number; title: string }[];
|
|
/** List of available voices from the backend. */
|
|
voices?: Voice[];
|
|
/** Called when the server returns 402 (free daily limit reached). */
|
|
onProRequired?: () => void;
|
|
/** Visual style of the player card.
|
|
* 'standard' = full inline card with voice/chapter controls;
|
|
* 'minimal' = compact single-row bar (play + seek + time only);
|
|
* 'float' = draggable overlay anchored bottom-right. */
|
|
playerStyle?: 'standard' | 'minimal' | 'float';
|
|
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
|
|
wordCount?: number;
|
|
}
|
|
|
|
let {
|
|
slug,
|
|
chapter,
|
|
chapterTitle = '',
|
|
bookTitle = '',
|
|
cover = '',
|
|
nextChapter = null,
|
|
chapters = [],
|
|
voices = [],
|
|
onProRequired = undefined,
|
|
playerStyle = 'standard',
|
|
wordCount = 0
|
|
}: Props = $props();
|
|
|
|
/** Estimated listen time in minutes at ~150 wpm average narration speed. */
|
|
const estimatedMinutes = $derived(wordCount > 0 ? Math.max(1, Math.round(wordCount / 150)) : 0);
|
|
|
|
// ── Derived: voices grouped by engine ──────────────────────────────────
|
|
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'));
|
|
|
|
// ── Voice selector state ────────────────────────────────────────────────
|
|
let showVoicePanel = $state(false);
|
|
/** Voice whose sample is currently being fetched or playing. */
|
|
let samplePlayingVoice = $state<string | null>(null);
|
|
/** Currently active sample <audio> element — one at a time. */
|
|
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
|
|
|
// ── Chapter picker state ─────────────────────────────────────────────────
|
|
let showChapterPanel = $state(false);
|
|
|
|
function playChapter(chapterNumber: number) {
|
|
audioStore.autoStartChapter = chapterNumber;
|
|
showChapterPanel = false;
|
|
goto(`/books/${slug}/chapters/${chapterNumber}`);
|
|
}
|
|
|
|
/**
|
|
* Human-readable label for a voice.
|
|
* Kokoro: "af_bella" → "Bella (US F)"
|
|
* Pocket-TTS: "alba" → "Alba (EN F)"
|
|
* CF AI: "cfai:luna" → "Luna (EN F)"
|
|
* Falls back gracefully if called with a bare string (e.g. from the store default).
|
|
*/
|
|
function voiceLabel(v: Voice | string): string {
|
|
// Handle plain string IDs stored in audioStore.voice
|
|
if (typeof v === 'string') {
|
|
// Try to match against the voices list
|
|
const found = voices.find((x) => x.id === v);
|
|
if (found) return voiceLabel(found);
|
|
// Bare kokoro ID fallback (legacy / default "af_bella")
|
|
return kokoroLabelFromId(v);
|
|
}
|
|
|
|
if (v.engine === 'cfai') {
|
|
// "cfai:luna" → "Luna (EN F)"
|
|
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
|
|
const name = speaker.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
const genderLabel = v.gender.toUpperCase();
|
|
return `${name} (EN ${genderLabel})`;
|
|
}
|
|
|
|
if (v.engine === 'pocket-tts') {
|
|
const langLabel = v.lang.toUpperCase().replace('-', '');
|
|
const genderLabel = v.gender.toUpperCase();
|
|
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
return `${name} (${langLabel} ${genderLabel})`;
|
|
}
|
|
|
|
// Kokoro
|
|
return kokoroLabelFromId(v.id);
|
|
}
|
|
|
|
function kokoroLabelFromId(id: string): string {
|
|
const langMap: Record<string, string> = {
|
|
af: 'US', am: 'US',
|
|
bf: 'UK', bm: 'UK',
|
|
ef: 'ES', em: 'ES',
|
|
ff: 'FR',
|
|
hf: 'IN', hm: 'IN',
|
|
'if': 'IT', im: 'IT',
|
|
jf: 'JP', jm: 'JP',
|
|
pf: 'PT', pm: 'PT',
|
|
zf: 'ZH', zm: 'ZH',
|
|
};
|
|
const genderMap: Record<string, string> = {
|
|
af: 'F', am: 'M',
|
|
bf: 'F', bm: 'M',
|
|
ef: 'F', em: 'M',
|
|
ff: 'F',
|
|
hf: 'F', hm: 'M',
|
|
'if': 'F', im: 'M',
|
|
jf: 'F', jm: 'M',
|
|
pf: 'F', pm: 'M',
|
|
zf: 'F', zm: 'M',
|
|
};
|
|
const prefix = id.slice(0, 2);
|
|
const name = id.slice(3);
|
|
const displayName = name
|
|
.replace(/^v0/, '')
|
|
.replace(/^([a-z])/, (c: string) => c.toUpperCase());
|
|
const lang = langMap[prefix] ?? prefix.toUpperCase();
|
|
const gender = genderMap[prefix] ?? '?';
|
|
return `${displayName} (${lang} ${gender})`;
|
|
}
|
|
|
|
/** Stop any currently playing sample. */
|
|
function stopSample() {
|
|
if (sampleAudio) {
|
|
sampleAudio.pause();
|
|
sampleAudio.src = '';
|
|
sampleAudio = null;
|
|
}
|
|
samplePlayingVoice = null;
|
|
}
|
|
|
|
/** Play a voice sample from MinIO. */
|
|
async function playSample(voice: string) {
|
|
// If this voice is already playing, stop it.
|
|
if (samplePlayingVoice === voice) {
|
|
stopSample();
|
|
return;
|
|
}
|
|
stopSample();
|
|
|
|
samplePlayingVoice = voice;
|
|
try {
|
|
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voice)}`);
|
|
if (res.status === 404) {
|
|
// Sample not generated yet — silently ignore
|
|
samplePlayingVoice = null;
|
|
return;
|
|
}
|
|
if (!res.ok) throw new Error(`presign failed: ${res.status}`);
|
|
const data = (await res.json()) as { url: string };
|
|
|
|
const audio = new Audio(data.url);
|
|
sampleAudio = audio;
|
|
audio.onended = () => {
|
|
if (samplePlayingVoice === voice) stopSample();
|
|
};
|
|
audio.onerror = () => {
|
|
if (samplePlayingVoice === voice) stopSample();
|
|
};
|
|
await audio.play();
|
|
} catch {
|
|
samplePlayingVoice = null;
|
|
}
|
|
}
|
|
|
|
/** Select a voice and close the panel. */
|
|
function selectVoice(voice: string) {
|
|
stopSample();
|
|
audioStore.voice = voice;
|
|
showVoicePanel = false;
|
|
}
|
|
|
|
// Keep nextChapter in the store so the layout's onended can navigate.
|
|
// We write null on mount (before deriving the real value) so there is no
|
|
// stale window where the previous chapter's nextChapter is still set while
|
|
// this chapter's AudioPlayer hasn't written its own value yet.
|
|
$effect(() => {
|
|
audioStore.nextChapter = nextChapter ?? null;
|
|
});
|
|
|
|
// Keep chapters list in store up to date so the layout's onended announce
|
|
// can find titles even if startPlayback() hasn't been called yet on this mount.
|
|
$effect(() => {
|
|
if (chapters.length > 0) audioStore.chapters = chapters;
|
|
});
|
|
|
|
// 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.
|
|
$effect(() => {
|
|
if (audioStore.autoStartChapter === chapter) {
|
|
audioStore.autoStartChapter = null;
|
|
startPlayback();
|
|
}
|
|
});
|
|
|
|
// Reset next-chapter prefetch state when this chapter changes (new page).
|
|
// Only reset if the prefetch belongs to neither the current chapter
|
|
// (about to be consumed by startPlayback) nor the next chapter (still valid).
|
|
// Any other value means stale data from a previous page.
|
|
$effect(() => {
|
|
const prefetchedFor = audioStore.nextChapterPrefetched;
|
|
if (
|
|
prefetchedFor !== null &&
|
|
prefetchedFor !== chapter &&
|
|
prefetchedFor !== (nextChapter ?? null)
|
|
) {
|
|
audioStore.resetNextPrefetch();
|
|
}
|
|
});
|
|
|
|
// Close panels on Escape.
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
if (showChapterPanel) { showChapterPanel = false; }
|
|
else { stopSample(); showVoicePanel = false; }
|
|
}
|
|
}
|
|
|
|
// ── 90% pre-fetch trigger ─────────────────────────────────────────────────
|
|
// Watch playback progress; when >= 90% of current chapter, pre-generate
|
|
// the next chapter's audio so it's ready when we navigate.
|
|
$effect(() => {
|
|
const ct = audioStore.currentTime;
|
|
const dur = audioStore.duration;
|
|
const isCurrentlyPlaying = audioStore.isCurrentChapter(slug, chapter);
|
|
|
|
if (
|
|
!isCurrentlyPlaying ||
|
|
!audioStore.autoNext ||
|
|
nextChapter === null ||
|
|
nextChapter === undefined ||
|
|
dur <= 0 ||
|
|
ct / dur < 0.9 ||
|
|
audioStore.nextStatus !== 'none'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Trigger exactly once (nextStatus transitions away from 'none')
|
|
prefetchNext();
|
|
});
|
|
|
|
// ── Pseudo progress helpers ────────────────────────────────────────────────
|
|
let progressRafId = 0;
|
|
|
|
function startProgress() {
|
|
audioStore.progress = 0;
|
|
let last = performance.now();
|
|
|
|
function tick(now: number) {
|
|
const dt = (now - last) / 1000;
|
|
last = now;
|
|
let rate: number;
|
|
if (audioStore.progress < 30) rate = 4;
|
|
else if (audioStore.progress < 60) rate = 12;
|
|
else if (audioStore.progress < 80) rate = 4;
|
|
else rate = 0.3;
|
|
|
|
audioStore.progress = Math.min(audioStore.progress + rate * dt, 99);
|
|
if (audioStore.progress < 99) {
|
|
progressRafId = requestAnimationFrame(tick);
|
|
}
|
|
}
|
|
progressRafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function stopProgress() {
|
|
if (progressRafId) {
|
|
cancelAnimationFrame(progressRafId);
|
|
progressRafId = 0;
|
|
}
|
|
}
|
|
|
|
async function finishProgress() {
|
|
stopProgress();
|
|
const step = () => {
|
|
audioStore.progress = Math.min(audioStore.progress + 8, 100);
|
|
if (audioStore.progress < 100) {
|
|
progressRafId = requestAnimationFrame(step);
|
|
}
|
|
};
|
|
progressRafId = requestAnimationFrame(step);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
stopProgress();
|
|
}
|
|
|
|
// ── Next-chapter pseudo-progress helpers ──────────────────────────────────
|
|
let nextProgressRafId = 0;
|
|
|
|
function startNextProgress() {
|
|
audioStore.nextProgress = 0;
|
|
let last = performance.now();
|
|
|
|
function tick(now: number) {
|
|
const dt = (now - last) / 1000;
|
|
last = now;
|
|
let rate: number;
|
|
if (audioStore.nextProgress < 30) rate = 4;
|
|
else if (audioStore.nextProgress < 60) rate = 12;
|
|
else if (audioStore.nextProgress < 80) rate = 4;
|
|
else rate = 0.3;
|
|
|
|
audioStore.nextProgress = Math.min(audioStore.nextProgress + rate * dt, 99);
|
|
if (audioStore.nextProgress < 99) {
|
|
nextProgressRafId = requestAnimationFrame(tick);
|
|
}
|
|
}
|
|
nextProgressRafId = requestAnimationFrame(tick);
|
|
}
|
|
|
|
function stopNextProgress() {
|
|
if (nextProgressRafId) {
|
|
cancelAnimationFrame(nextProgressRafId);
|
|
nextProgressRafId = 0;
|
|
}
|
|
}
|
|
|
|
// ── API helpers ────────────────────────────────────────────────────────────
|
|
|
|
type PresignResult =
|
|
| { ready: true; url: string }
|
|
| { ready: false; enqueued: boolean }; // enqueued=true → presign already POSTed
|
|
|
|
async function tryPresign(
|
|
targetSlug: string,
|
|
targetChapter: number,
|
|
targetVoice: string
|
|
): Promise<PresignResult> {
|
|
const params = new URLSearchParams({
|
|
slug: targetSlug,
|
|
n: String(targetChapter),
|
|
voice: targetVoice
|
|
});
|
|
const res = await fetch(`/api/presign/audio?${params}`);
|
|
// 202: presign endpoint already triggered TTS — skip the POST, go straight to polling.
|
|
// 404: legacy fallback (should no longer occur after endpoint change).
|
|
if (res.status === 202) return { ready: false, enqueued: true };
|
|
if (res.status === 404) return { ready: false, enqueued: false };
|
|
if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
|
|
const data = (await res.json()) as { url: string };
|
|
return { ready: true, url: data.url };
|
|
}
|
|
|
|
type AudioStatusResponse =
|
|
| { status: 'done' }
|
|
| { status: 'pending' | 'generating'; job_id: string }
|
|
| { status: 'idle' }
|
|
| { status: 'failed'; error?: string };
|
|
|
|
/**
|
|
* Poll GET /api/audio/status/[slug]/[n]?voice=... every `intervalMs` ms
|
|
* until status is "done" or "failed" (or the caller cancels via signal).
|
|
*
|
|
* Returns the final status response, or throws on network error / cancellation.
|
|
*/
|
|
async function pollAudioStatus(
|
|
targetSlug: string,
|
|
targetChapter: number,
|
|
targetVoice: string,
|
|
intervalMs = 2000,
|
|
signal?: AbortSignal
|
|
): Promise<AudioStatusResponse> {
|
|
const qs = new URLSearchParams();
|
|
if (targetVoice) qs.set('voice', targetVoice);
|
|
const url = `/api/audio/status/${targetSlug}/${targetChapter}?${qs.toString()}`;
|
|
|
|
while (true) {
|
|
if (signal?.aborted) throw new DOMException('Aborted', 'AbortError');
|
|
|
|
const res = await fetch(url, { signal });
|
|
if (!res.ok) throw new Error(`Status poll HTTP ${res.status}`);
|
|
const data = (await res.json()) as AudioStatusResponse;
|
|
|
|
if (data.status === 'done' || data.status === 'failed') {
|
|
return data;
|
|
}
|
|
|
|
// Still pending/generating — wait then retry.
|
|
await new Promise<void>((resolve, reject) => {
|
|
const timer = setTimeout(resolve, intervalMs);
|
|
signal?.addEventListener('abort', () => {
|
|
clearTimeout(timer);
|
|
reject(new DOMException('Aborted', 'AbortError'));
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Pre-fetch next chapter ─────────────────────────────────────────────────
|
|
|
|
async function prefetchNext() {
|
|
if (nextChapter === null || nextChapter === undefined) return;
|
|
if (audioStore.nextStatus !== 'none') return; // already running or done
|
|
|
|
const voice = audioStore.voice;
|
|
|
|
audioStore.nextStatus = 'prefetching';
|
|
audioStore.nextChapterPrefetched = nextChapter;
|
|
startNextProgress();
|
|
|
|
try {
|
|
// Fast path: already generated
|
|
const presignResult = await tryPresign(slug, nextChapter, voice);
|
|
if (presignResult.ready) {
|
|
stopNextProgress();
|
|
audioStore.nextProgress = 100;
|
|
audioStore.nextAudioUrl = presignResult.url;
|
|
audioStore.nextStatus = 'prefetched';
|
|
return;
|
|
}
|
|
|
|
// Slow path: trigger generation (or skip POST if presign already enqueued).
|
|
if (!presignResult.enqueued) {
|
|
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice })
|
|
});
|
|
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
|
|
|
|
if (res.status === 200) {
|
|
// Body is { status: 'done' } — audio confirmed in MinIO. Presign it.
|
|
await res.body?.cancel();
|
|
stopNextProgress();
|
|
audioStore.nextProgress = 100;
|
|
const doneUrl = await tryPresign(slug, nextChapter, voice);
|
|
if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404');
|
|
audioStore.nextAudioUrl = doneUrl.url;
|
|
audioStore.nextStatus = 'prefetched';
|
|
return;
|
|
}
|
|
// 202: generation enqueued — fall through to poll.
|
|
}
|
|
|
|
// Poll until done (covers both: presign-enqueued and POST-enqueued paths).
|
|
const final = await pollAudioStatus(slug, nextChapter, voice);
|
|
stopNextProgress();
|
|
audioStore.nextProgress = 100;
|
|
|
|
if (final.status === 'failed') {
|
|
throw new Error(`Prefetch failed: ${(final as { error?: string }).error ?? 'unknown'}`);
|
|
}
|
|
|
|
// Audio is ready in MinIO — get a direct presigned URL.
|
|
const doneUrl = await tryPresign(slug, nextChapter, voice);
|
|
if (!doneUrl.ready) throw new Error('Prefetch: audio done but presign returned 404');
|
|
|
|
audioStore.nextAudioUrl = doneUrl.url;
|
|
audioStore.nextStatus = 'prefetched';
|
|
} catch {
|
|
stopNextProgress();
|
|
audioStore.nextStatus = 'failed';
|
|
}
|
|
}
|
|
|
|
// ── Media Session ──────────────────────────────────────────────────────────
|
|
// Sets the OS-level media metadata so the book cover, title, and chapter
|
|
// appear on the phone lock screen / notification center.
|
|
|
|
function setMediaSession() {
|
|
if (typeof navigator === 'undefined' || !('mediaSession' in navigator)) return;
|
|
|
|
const artwork: MediaImage[] = cover
|
|
? [
|
|
{ src: cover, sizes: '512x512', type: 'image/jpeg' },
|
|
{ src: cover, sizes: '256x256', type: 'image/jpeg' }
|
|
]
|
|
: [];
|
|
|
|
navigator.mediaSession.metadata = new MediaMetadata({
|
|
title: chapterTitle || `Chapter ${chapter}`,
|
|
artist: bookTitle,
|
|
album: bookTitle,
|
|
artwork
|
|
});
|
|
}
|
|
|
|
// ── Core play flow ─────────────────────────────────────────────────────────
|
|
|
|
async function startPlayback() {
|
|
const voice = audioStore.voice;
|
|
|
|
// Populate store metadata so layout + mini-bar have track info.
|
|
audioStore.slug = slug;
|
|
audioStore.chapter = chapter;
|
|
audioStore.chapterTitle = chapterTitle;
|
|
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();
|
|
|
|
audioStore.status = 'loading';
|
|
audioStore.errorMsg = '';
|
|
|
|
try {
|
|
// Fast path A: pre-fetch already confirmed audio is in MinIO for THIS chapter.
|
|
// Re-presign instead of using the cached URL — it may have expired if the
|
|
// user paused for a while between the prefetch and actually reaching this chapter.
|
|
if (
|
|
audioStore.nextStatus === 'prefetched' &&
|
|
audioStore.nextChapterPrefetched === chapter
|
|
) {
|
|
// Consume the pre-fetch state first so it doesn't carry over on error.
|
|
audioStore.resetNextPrefetch();
|
|
// Fresh presign — audio is confirmed in MinIO so this is a fast, cheap call.
|
|
const presigned = await tryPresign(slug, chapter, voice);
|
|
if (presigned.ready) {
|
|
audioStore.audioUrl = presigned.url;
|
|
audioStore.status = 'ready';
|
|
// Don't restore saved time for auto-next; position is 0.
|
|
// Immediately start pre-generating the chapter after this one.
|
|
maybeStartPrefetch();
|
|
return;
|
|
}
|
|
// Presign returned not-ready (race: MinIO object vanished?).
|
|
// Fall through to the normal slow path below.
|
|
}
|
|
|
|
// Fast path B: audio already in MinIO (presign check).
|
|
const presignResult = await tryPresign(slug, chapter, voice);
|
|
if (presignResult.ready) {
|
|
audioStore.audioUrl = presignResult.url;
|
|
audioStore.status = 'ready';
|
|
// Restore last saved position after the audio element loads
|
|
restoreSavedAudioTime();
|
|
// Immediately start pre-generating the next chapter in background.
|
|
maybeStartPrefetch();
|
|
return;
|
|
}
|
|
|
|
// Slow path: audio not yet in MinIO.
|
|
//
|
|
// For Kokoro / PocketTTS in 'stream' mode: use the streaming endpoint so
|
|
// audio starts playing within seconds. The stream handler checks MinIO
|
|
// first (fast redirect if already cached) and otherwise generates +
|
|
// uploads concurrently.
|
|
//
|
|
// In 'generate' mode (user preference): queue a runner task and poll,
|
|
// same as CF AI — audio plays only after the full file is ready in MinIO.
|
|
if (!voice.startsWith('cfai:') && audioStore.audioMode === 'stream') {
|
|
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
|
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
|
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
|
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
|
const format = isPocketTTS ? 'wav' : 'mp3';
|
|
const qs = new URLSearchParams({ voice, format });
|
|
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
|
// HEAD probe: check paywall without triggering generation.
|
|
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
|
if (headRes?.status === 402) {
|
|
audioStore.status = 'idle';
|
|
onProRequired?.();
|
|
return;
|
|
}
|
|
audioStore.audioUrl = streamUrl;
|
|
audioStore.status = 'ready';
|
|
maybeStartPrefetch();
|
|
return;
|
|
}
|
|
|
|
// Non-CF AI voices in 'generate' mode: queue runner task, show progress,
|
|
// wait for full audio in MinIO before playing (same as CF AI but no preview).
|
|
if (!voice.startsWith('cfai:')) {
|
|
audioStore.status = 'generating';
|
|
audioStore.isPreview = false;
|
|
startProgress();
|
|
|
|
if (!presignResult.enqueued) {
|
|
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice })
|
|
});
|
|
|
|
if (res.status === 402) {
|
|
audioStore.status = 'idle';
|
|
stopProgress();
|
|
onProRequired?.();
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
|
|
|
if (res.status === 200) {
|
|
await res.body?.cancel();
|
|
await finishProgress();
|
|
const doneUrl = await tryPresign(slug, chapter, voice);
|
|
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
|
audioStore.audioUrl = doneUrl.url;
|
|
audioStore.status = 'ready';
|
|
restoreSavedAudioTime();
|
|
maybeStartPrefetch();
|
|
return;
|
|
}
|
|
// 202 — runner task enqueued, fall through to poll.
|
|
}
|
|
|
|
const final = await pollAudioStatus(slug, chapter, voice);
|
|
if (final.status === 'failed') {
|
|
throw new Error(
|
|
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
|
);
|
|
}
|
|
|
|
await finishProgress();
|
|
const doneUrl = await tryPresign(slug, chapter, voice);
|
|
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
|
audioStore.audioUrl = doneUrl.url;
|
|
audioStore.status = 'ready';
|
|
restoreSavedAudioTime();
|
|
maybeStartPrefetch();
|
|
return;
|
|
}
|
|
|
|
// CF AI voices: use preview/swap strategy.
|
|
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
|
// so playback starts immediately — no more waiting behind a spinner.
|
|
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
|
// swap the <audio> src to the full URL preserving currentTime.
|
|
audioStore.status = 'generating';
|
|
audioStore.isPreview = false;
|
|
startProgress();
|
|
|
|
// Kick off the full audio generation task in the background
|
|
// (presignResult.enqueued=true means the presign endpoint already did it).
|
|
if (!presignResult.enqueued) {
|
|
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice })
|
|
});
|
|
|
|
if (res.status === 402) {
|
|
audioStore.status = 'idle';
|
|
stopProgress();
|
|
onProRequired?.();
|
|
return;
|
|
}
|
|
|
|
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
|
|
|
if (res.status === 200) {
|
|
// Already cached — fast path: presign and play directly.
|
|
await res.body?.cancel();
|
|
await finishProgress();
|
|
const doneUrl = await tryPresign(slug, chapter, voice);
|
|
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
|
audioStore.isPreview = false;
|
|
audioStore.audioUrl = doneUrl.url;
|
|
audioStore.status = 'ready';
|
|
maybeStartPrefetch();
|
|
return;
|
|
}
|
|
// 202 accepted — fall through: start preview while runner generates
|
|
}
|
|
|
|
// Fetch the preview clip (first ~1-2 min chunk).
|
|
// Use an AbortController so we can cancel the background polling if the
|
|
// user navigates away or stops playback before the full audio is ready.
|
|
const previewAbort = new AbortController();
|
|
const qs = new URLSearchParams({ voice });
|
|
const previewUrl = `/api/audio-preview/${slug}/${chapter}?${qs}`;
|
|
|
|
try {
|
|
const previewRes = await fetch(previewUrl, { signal: previewAbort.signal });
|
|
if (previewRes.status === 402) {
|
|
audioStore.status = 'idle';
|
|
stopProgress();
|
|
onProRequired?.();
|
|
return;
|
|
}
|
|
if (!previewRes.ok) throw new Error(`Preview failed: HTTP ${previewRes.status}`);
|
|
|
|
// The backend responded with the MP3 bytes (or a redirect to MinIO).
|
|
// Build a blob URL so we can swap it out later without reloading the page.
|
|
const previewBlob = await previewRes.blob();
|
|
const previewBlobUrl = URL.createObjectURL(previewBlob);
|
|
|
|
audioStore.isPreview = true;
|
|
audioStore.audioUrl = previewBlobUrl;
|
|
audioStore.status = 'ready';
|
|
// Don't restore saved time here — preview is always from 0.
|
|
// Kick off prefetch of next chapter in the background.
|
|
maybeStartPrefetch();
|
|
} catch (previewErr: unknown) {
|
|
if (previewErr instanceof DOMException && previewErr.name === 'AbortError') return;
|
|
// Preview failed — fall through to the spinner (old behaviour).
|
|
// We'll wait for the full audio to finish instead.
|
|
audioStore.isPreview = false;
|
|
}
|
|
|
|
// Background: poll for full audio; when done, swap src preserving position.
|
|
try {
|
|
const final = await pollAudioStatus(slug, chapter, voice, 2000, previewAbort.signal);
|
|
if (final.status === 'failed') {
|
|
throw new Error(
|
|
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
|
);
|
|
}
|
|
|
|
await finishProgress();
|
|
|
|
const doneUrl = await tryPresign(slug, chapter, voice);
|
|
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
|
|
|
// Swap: save currentTime → update URL → seek to saved position.
|
|
const savedTime = audioStore.currentTime;
|
|
const blobUrlToRevoke = audioStore.audioUrl; // capture before overwrite
|
|
audioStore.isPreview = false;
|
|
audioStore.audioUrl = doneUrl.url;
|
|
// If we never started a preview (preview fetch failed), switch to ready now.
|
|
if (audioStore.status !== 'ready') audioStore.status = 'ready';
|
|
// The layout $effect will load the new src and auto-play from 0.
|
|
// We seek back to savedTime after a short delay to let the element
|
|
// attach the new source before accepting a seek.
|
|
if (savedTime > 0) {
|
|
setTimeout(() => {
|
|
audioStore.seekRequest = savedTime;
|
|
}, 300);
|
|
}
|
|
// Revoke the preview blob URL to free memory.
|
|
// (We need to wait until the new src is playing; 2 s is safe.)
|
|
setTimeout(() => {
|
|
if (blobUrlToRevoke.startsWith('blob:')) {
|
|
URL.revokeObjectURL(blobUrlToRevoke);
|
|
}
|
|
}, 2000);
|
|
maybeStartPrefetch();
|
|
} catch (pollErr: unknown) {
|
|
if (pollErr instanceof DOMException && pollErr.name === 'AbortError') return;
|
|
throw pollErr;
|
|
}
|
|
} catch (e) {
|
|
stopProgress();
|
|
audioStore.progress = 0;
|
|
audioStore.status = 'error';
|
|
audioStore.errorMsg = String(e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start pre-fetching the next chapter if autoNext is on, there is a next
|
|
* chapter, and no prefetch is already running or completed.
|
|
* Called as soon as current-chapter playback begins so that the next
|
|
* chapter's audio is ready before we need it (seamless transition).
|
|
* The 90%-mark $effect acts as a fallback for cases where autoNext is
|
|
* toggled on mid-playback.
|
|
*/
|
|
function maybeStartPrefetch() {
|
|
if (
|
|
audioStore.autoNext &&
|
|
nextChapter !== null &&
|
|
nextChapter !== undefined &&
|
|
audioStore.nextStatus === 'none'
|
|
) {
|
|
prefetchNext();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the saved audio time for this chapter and seek to it after a short
|
|
* delay (to allow the audio element to load the source).
|
|
*/
|
|
async function restoreSavedAudioTime() {
|
|
try {
|
|
const params = new URLSearchParams({ slug, chapter: String(chapter) });
|
|
const res = await fetch(`/api/progress/audio-time?${params}`);
|
|
if (!res.ok) return;
|
|
const data = (await res.json()) as { audioTime: number | null };
|
|
if (data.audioTime && data.audioTime > 5) {
|
|
// Small delay to let the <audio> element fully load the src before seeking
|
|
setTimeout(() => {
|
|
audioStore.seekRequest = data.audioTime as number;
|
|
}, 300);
|
|
}
|
|
} catch {
|
|
// Non-critical — silently ignore
|
|
}
|
|
}
|
|
|
|
async function handlePlay() {
|
|
const isCurrent = audioStore.isCurrentChapter(slug, chapter);
|
|
|
|
// Already loaded this chapter: toggle play/pause.
|
|
if (isCurrent && audioStore.status === 'ready') {
|
|
audioStore.toggleRequest = (audioStore.toggleRequest ?? 0) + 1;
|
|
return;
|
|
}
|
|
|
|
// Not yet loaded — start the full flow.
|
|
await startPlayback();
|
|
|
|
// Track audio play after successful load.
|
|
if (audioStore.status === 'ready') {
|
|
window.umami?.track('audio_played', { slug, chapter });
|
|
}
|
|
}
|
|
|
|
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);
|
|
const sec = Math.floor(s % 60);
|
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// ── Sleep timer ────────────────────────────────────────────────────────────
|
|
const SLEEP_OPTIONS = [15, 30, 45, 60]; // minutes
|
|
|
|
let _tick = $state(0);
|
|
$effect(() => {
|
|
if (!audioStore.sleepUntil) return;
|
|
const id = setInterval(() => { _tick++; }, 1000);
|
|
return () => clearInterval(id);
|
|
});
|
|
|
|
let sleepRemainingSec = $derived.by(() => {
|
|
_tick; // subscribe to tick updates
|
|
if (!audioStore.sleepUntil) return 0;
|
|
return Math.max(0, Math.floor((audioStore.sleepUntil - Date.now()) / 1000));
|
|
});
|
|
|
|
function cycleSleepTimer() {
|
|
// Currently: no timer active at all
|
|
if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) {
|
|
audioStore.sleepAfterChapter = true;
|
|
return;
|
|
}
|
|
// Currently: end-of-chapter mode — move to 15m
|
|
if (audioStore.sleepAfterChapter) {
|
|
audioStore.sleepAfterChapter = false;
|
|
audioStore.sleepUntil = Date.now() + SLEEP_OPTIONS[0] * 60 * 1000;
|
|
return;
|
|
}
|
|
// Currently: timed mode — cycle to next or turn off
|
|
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 '';
|
|
const m = Math.floor(secs / 60);
|
|
const s = secs % 60;
|
|
if (m > 0) return `${m}m`;
|
|
return `${s}s`;
|
|
}
|
|
|
|
// ── Compact player helpers ─────────────────────────────────────────────────
|
|
const playPct = $derived(
|
|
audioStore.duration > 0 ? (audioStore.currentTime / audioStore.duration) * 100 : 0
|
|
);
|
|
|
|
function seekFromBar(e: MouseEvent) {
|
|
if (audioStore.duration <= 0) return;
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
audioStore.seekRequest = pct * audioStore.duration;
|
|
}
|
|
|
|
// ── Float player drag state ──────────────────────────────────────────────
|
|
// floatPos lives on audioStore (singleton) so position survives chapter navigation.
|
|
// Coordinate system: x/y are offsets from bottom-right corner (positive = toward center).
|
|
// right = calc(1rem + {-x}px) → x=0 means right:1rem, x=-50 means right:3.125rem
|
|
// bottom = calc(1rem + {-y}px) → y=0 means bottom:1rem
|
|
//
|
|
// To keep the circle in the viewport we clamp so that the element never goes
|
|
// outside any edge. Circle size = 56px (w-14), margin = 16px (1rem).
|
|
|
|
const FLOAT_SIZE = 56; // px — must match w-14
|
|
const FLOAT_MARGIN = 16; // px — 1rem
|
|
|
|
function clampFloatPos(x: number, y: number): { x: number; y: number } {
|
|
const vw = typeof window !== 'undefined' ? window.innerWidth : 400;
|
|
const vh = typeof window !== 'undefined' ? window.innerHeight : 800;
|
|
// right edge: element right = 1rem - x ≥ 0 → x ≤ FLOAT_MARGIN
|
|
const maxX = FLOAT_MARGIN;
|
|
// left edge: element right + size ≤ vw → right = 1rem - x → 1rem - x + size ≤ vw
|
|
// x ≥ FLOAT_MARGIN + FLOAT_SIZE - vw
|
|
const minX = FLOAT_MARGIN + FLOAT_SIZE - vw;
|
|
// top edge: element bottom + size ≤ vh → bottom = 1rem - y → 1rem - y + size ≤ vh
|
|
// y ≥ FLOAT_MARGIN + FLOAT_SIZE - vh
|
|
const minY = FLOAT_MARGIN + FLOAT_SIZE - vh;
|
|
// bottom edge: element bottom = 1rem - y ≥ 0 → y ≤ FLOAT_MARGIN
|
|
const maxY = FLOAT_MARGIN;
|
|
return {
|
|
x: Math.max(minX, Math.min(maxX, x)),
|
|
y: Math.max(minY, Math.min(maxY, y)),
|
|
};
|
|
}
|
|
|
|
let floatDragging = $state(false);
|
|
let floatDragStart = $state({ mx: 0, my: 0, ox: 0, oy: 0 });
|
|
// Track total pointer movement to distinguish tap vs drag
|
|
let floatMoved = $state(false);
|
|
|
|
function onFloatPointerDown(e: PointerEvent) {
|
|
e.stopPropagation();
|
|
floatDragging = true;
|
|
floatMoved = false;
|
|
floatDragStart = { mx: e.clientX, my: e.clientY, ox: audioStore.floatPos.x, oy: audioStore.floatPos.y };
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
}
|
|
function onFloatPointerMove(e: PointerEvent) {
|
|
if (!floatDragging) return;
|
|
const dx = e.clientX - floatDragStart.mx;
|
|
const dy = e.clientY - floatDragStart.my;
|
|
// Only start moving if dragged > 6px to preserve tap detection
|
|
if (!floatMoved && Math.hypot(dx, dy) < 6) return;
|
|
floatMoved = true;
|
|
// right = MARGIN - x → drag right (dx>0) should decrease right → x increases → x = ox + dx
|
|
// bottom = MARGIN - y → drag down (dy>0) should decrease bottom → y increases → y = oy + dy
|
|
const raw = {
|
|
x: floatDragStart.ox + dx,
|
|
y: floatDragStart.oy + dy,
|
|
};
|
|
audioStore.floatPos = clampFloatPos(raw.x, raw.y);
|
|
}
|
|
function onFloatPointerUp(e: PointerEvent) {
|
|
if (!floatDragging) return;
|
|
if (floatDragging && !floatMoved) {
|
|
// Tap: toggle play/pause
|
|
audioStore.toggleRequest++;
|
|
}
|
|
floatDragging = false;
|
|
try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
|
}
|
|
|
|
// Clamp saved position to viewport on mount and on resize.
|
|
// Use untrack() when reading floatPos to avoid a reactive loop
|
|
// (reading + writing the same state inside $effect would re-trigger forever).
|
|
$effect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
const clamp = () => {
|
|
const { x, y } = untrack(() => audioStore.floatPos);
|
|
audioStore.floatPos = clampFloatPos(x, y);
|
|
};
|
|
clamp();
|
|
window.addEventListener('resize', clamp);
|
|
return () => window.removeEventListener('resize', clamp);
|
|
});
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeyDown} />
|
|
|
|
<!-- ── Voice row snippet (reused in both engine sections) ──────────────── -->
|
|
{#snippet voiceRow(v: import('$lib/types').Voice)}
|
|
<div
|
|
class={cn('flex items-center gap-2 px-3 py-2 hover:bg-(--color-surface-2) transition-colors cursor-pointer', audioStore.voice === v.id && 'bg-(--color-brand)/10')}
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => selectVoice(v.id)}
|
|
onkeydown={(e) => e.key === 'Enter' && selectVoice(v.id)}
|
|
>
|
|
<!-- Selected indicator -->
|
|
<div class="w-4 flex-shrink-0">
|
|
{#if audioStore.voice === v.id}
|
|
<svg class="w-3.5 h-3.5 text-(--color-brand)" 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>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Voice name -->
|
|
<span class={cn('flex-1 text-xs', audioStore.voice === v.id ? 'text-(--color-brand) font-medium' : 'text-(--color-text)')}>
|
|
{voiceLabel(v)}
|
|
</span>
|
|
<span class="text-(--color-muted) opacity-60 text-xs font-mono">{v.id}</span>
|
|
|
|
<!-- Sample play button -->
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class={cn('h-6 w-6 flex-shrink-0', samplePlayingVoice === v.id ? 'text-(--color-brand) bg-(--color-brand)/15 hover:bg-(--color-brand)/25' : 'text-(--color-muted) hover:text-(--color-text)')}
|
|
onclick={(e) => { e.stopPropagation(); playSample(v.id); }}
|
|
title={samplePlayingVoice === v.id ? m.reader_voice_stop_sample() : m.reader_voice_play_sample()}
|
|
aria-label={samplePlayingVoice === v.id ? `Stop ${v.id} sample` : `Play ${v.id} sample`}
|
|
>
|
|
{#if samplePlayingVoice === v.id}
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 6h12v12H6z"/>
|
|
</svg>
|
|
{:else}
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
{/if}
|
|
</Button>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
|
|
|
{#if
|
|
(!audioStore.isCurrentChapter(slug, chapter) && !audioStore.active) ||
|
|
(audioStore.isCurrentChapter(slug, chapter) && (audioStore.status === 'idle' || audioStore.status === 'error'))
|
|
}
|
|
<!-- ── Idle / not-yet-started pill ─────────────────────────────────────────── -->
|
|
<div class="px-3 py-2.5">
|
|
{#if audioStore.isCurrentChapter(slug, chapter) && audioStore.status === 'error'}
|
|
<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
|
{/if}
|
|
<div class="flex items-center gap-3">
|
|
<!-- Big play button -->
|
|
<button
|
|
type="button"
|
|
onclick={handlePlay}
|
|
class="w-11 h-11 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0 shadow-sm"
|
|
aria-label={m.reader_play_narration()}
|
|
>
|
|
<svg class="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Track info (hidden in minimal style) -->
|
|
{#if playerStyle !== 'minimal'}
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
|
|
{m.reader_play_narration()}
|
|
</p>
|
|
<div class="flex items-center gap-1.5 mt-0.5">
|
|
<!-- Voice indicator -->
|
|
{#if voices.length > 0}
|
|
<button
|
|
type="button"
|
|
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()}
|
|
>
|
|
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
|
</svg>
|
|
<span class="max-w-[90px] truncate">{voiceLabel(audioStore.voice)}</span>
|
|
<svg class={cn('w-2.5 h-2.5 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M7 10l5 5 5-5z"/>
|
|
</svg>
|
|
</button>
|
|
{/if}
|
|
<!-- Estimated duration -->
|
|
{#if estimatedMinutes > 0}
|
|
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
|
|
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
|
|
{/if}
|
|
<!-- Stream / Generate mode toggle -->
|
|
{#if !audioStore.voice.startsWith('cfai:')}
|
|
<span class="text-(--color-border) text-xs leading-none">·</span>
|
|
<button
|
|
type="button"
|
|
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
|
|
class={cn(
|
|
'flex items-center gap-0.5 text-xs leading-none transition-colors',
|
|
audioStore.audioMode === 'stream'
|
|
? 'text-(--color-brand)'
|
|
: 'text-(--color-muted) hover:text-(--color-text)'
|
|
)}
|
|
title={audioStore.audioMode === 'stream' ? 'Stream mode — click to switch to generate' : 'Generate mode — click to switch to stream'}
|
|
>
|
|
{#if audioStore.audioMode === 'stream'}
|
|
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
Stream
|
|
{:else}
|
|
<svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
|
</svg>
|
|
Generate
|
|
{/if}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Chapters button (right side, hidden in minimal style) -->
|
|
{#if chapters.length > 0 && playerStyle !== 'minimal'}
|
|
<button
|
|
type="button"
|
|
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
|
|
class={cn('flex items-center gap-1 px-2 py-1.5 rounded-md text-xs transition-colors flex-shrink-0', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)')}
|
|
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>
|
|
<span class="hidden sm:inline">Chapters</span>
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Voice selector panel (inline below pill) -->
|
|
{#if showVoicePanel && voices.length > 0}
|
|
<div class="mt-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
|
|
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
|
|
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
|
onclick={() => { stopSample(); showVoicePanel = false; }}
|
|
aria-label={m.reader_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>
|
|
<div class="max-h-64 overflow-y-auto">
|
|
{#if kokoroVoices.length > 0}
|
|
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
|
|
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
|
|
</div>
|
|
{#each kokoroVoices as v (v.id)}{@render voiceRow(v)}{/each}
|
|
{/if}
|
|
{#if pocketVoices.length > 0}
|
|
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
|
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
|
|
</div>
|
|
{#each pocketVoices as v (v.id)}{@render voiceRow(v)}{/each}
|
|
{/if}
|
|
{#if cfaiVoices.length > 0}
|
|
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
|
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
|
|
</div>
|
|
{#each cfaiVoices as v (v.id)}{@render voiceRow(v)}{/each}
|
|
{/if}
|
|
</div>
|
|
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
|
|
<p class="text-xs text-(--color-muted)">
|
|
{m.reader_voice_applies_next()}
|
|
{#if voices.length > 0}
|
|
<a
|
|
href="/api/audio/voice-samples"
|
|
class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
|
|
onclick={(e) => {
|
|
e.preventDefault();
|
|
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
|
}}
|
|
>{m.reader_generate_samples()}</a>
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{:else}
|
|
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
|
|
{#if !(playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active)}
|
|
|
|
{#if playerStyle === 'minimal' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
|
<!-- ── Minimal style: compact bar — seek + play/pause + skip + time ────────── -->
|
|
<div class="px-3 py-2.5 flex items-center gap-2">
|
|
<!-- Skip back 15s -->
|
|
<button
|
|
type="button"
|
|
onclick={() => { audioStore.seekRequest = Math.max(0, audioStore.currentTime - 15); }}
|
|
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
|
title="-15s"
|
|
>
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M11.99 5V1l-5 5 5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6h-2c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Play/pause -->
|
|
<button
|
|
type="button"
|
|
onclick={() => { audioStore.toggleRequest++; }}
|
|
class="flex-shrink-0 w-8 h-8 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all"
|
|
>
|
|
{#if audioStore.isPlaying}
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/></svg>
|
|
{:else}
|
|
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
|
{/if}
|
|
</button>
|
|
|
|
<!-- Skip forward 30s -->
|
|
<button
|
|
type="button"
|
|
onclick={() => { audioStore.seekRequest = Math.min(audioStore.duration || 0, audioStore.currentTime + 30); }}
|
|
class="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3) transition-colors"
|
|
title="+30s"
|
|
>
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<!-- Seek bar — proper range input so drag works on iOS too -->
|
|
<input
|
|
type="range"
|
|
aria-label="Seek"
|
|
min="0"
|
|
max={audioStore.duration || 0}
|
|
value={audioStore.currentTime}
|
|
oninput={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
|
|
onchange={(e) => { audioStore.seekRequest = parseFloat((e.target as HTMLInputElement).value); }}
|
|
class="flex-1 h-1.5 cursor-pointer"
|
|
style="accent-color: var(--color-brand);"
|
|
/>
|
|
|
|
<!-- Time -->
|
|
<span class="flex-shrink-0 text-[11px] tabular-nums text-(--color-muted)">
|
|
{formatTime(audioStore.currentTime)}<span class="opacity-40">/</span>{formatDuration(audioStore.duration)}
|
|
</span>
|
|
</div>
|
|
|
|
{:else}
|
|
<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; 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()}
|
|
>
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
|
</svg>
|
|
<span class="max-w-[80px] truncate">{voiceLabel(audioStore.voice)}</span>
|
|
<svg class={cn('w-3 h-3 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M7 10l5 5 5-5z"/>
|
|
</svg>
|
|
</Button>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- ── Voice selector panel ──────────────────────────────────────────── -->
|
|
{#if showVoicePanel && voices.length > 0}
|
|
<div class="mb-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
|
|
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
|
|
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
|
onclick={() => { stopSample(); showVoicePanel = false; }}
|
|
aria-label={m.reader_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>
|
|
<div class="max-h-64 overflow-y-auto">
|
|
<!-- Kokoro (GPU) section -->
|
|
{#if kokoroVoices.length > 0}
|
|
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
|
|
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
|
|
</div>
|
|
{#each kokoroVoices as v (v.id)}
|
|
{@render voiceRow(v)}
|
|
{/each}
|
|
{/if}
|
|
|
|
<!-- Pocket TTS (CPU) section -->
|
|
{#if pocketVoices.length > 0}
|
|
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
|
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
|
|
</div>
|
|
{#each pocketVoices as v (v.id)}
|
|
{@render voiceRow(v)}
|
|
{/each}
|
|
{/if}
|
|
|
|
<!-- Cloudflare AI section -->
|
|
{#if cfaiVoices.length > 0}
|
|
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
|
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
|
|
</div>
|
|
{#each cfaiVoices as v (v.id)}
|
|
{@render voiceRow(v)}
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
|
|
<p class="text-xs text-(--color-muted)">
|
|
{m.reader_voice_applies_next()}
|
|
{#if voices.length > 0}
|
|
<a
|
|
href="/api/audio/voice-samples"
|
|
class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
|
|
onclick={(e) => {
|
|
e.preventDefault();
|
|
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
|
}}
|
|
>{m.reader_generate_samples()}</a>
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
|
|
{#if audioStore.isCurrentChapter(slug, chapter)}
|
|
<!-- ── This chapter is the active one (non-idle states) ── -->
|
|
|
|
{#if audioStore.status === 'loading'}
|
|
<Button variant="default" size="sm" disabled>
|
|
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
{m.player_loading()}
|
|
</Button>
|
|
|
|
{:else if audioStore.status === 'generating'}
|
|
<div class="space-y-2">
|
|
<p class="text-xs text-(--color-muted)">{m.reader_generating_narration()}</p>
|
|
<div class="w-full h-1.5 bg-(--color-surface-3) rounded-full overflow-hidden">
|
|
<div
|
|
class="h-full bg-(--color-brand) rounded-full transition-none"
|
|
style="width: {audioStore.progress}%"
|
|
></div>
|
|
</div>
|
|
<p class="text-xs text-(--color-muted) opacity-60 tabular-nums">{Math.round(audioStore.progress)}%</p>
|
|
</div>
|
|
|
|
{:else if audioStore.status === 'ready'}
|
|
<!-- Mini-bar is the canonical control surface — show a compact indicator here -->
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="flex items-center gap-2 text-xs text-(--color-muted)">
|
|
{#if audioStore.isPlaying}
|
|
<svg class="w-3.5 h-3.5 text-(--color-brand) flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
|
</svg>
|
|
<span>{m.reader_playing()}</span>
|
|
{:else}
|
|
<svg class="w-3.5 h-3.5 flex-shrink-0 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
<span>{m.reader_paused()}</span>
|
|
{/if}
|
|
<span class="tabular-nums text-(--color-muted) opacity-60">
|
|
{formatTime(audioStore.currentTime)} / {formatDuration(audioStore.duration)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Warm-up next chapter (only when autoNext is off and next exists) -->
|
|
{#if !audioStore.autoNext && nextChapter != null}
|
|
<div class="mt-2 flex items-center gap-2">
|
|
{#if audioStore.nextStatus === 'none'}
|
|
<button
|
|
type="button"
|
|
onclick={prefetchNext}
|
|
class="flex items-center gap-1.5 text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
|
title="Pre-generate audio for the next chapter"
|
|
>
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M7 2l10 10L7 22z"/>
|
|
</svg>
|
|
Warm up Ch. {nextChapter}
|
|
</button>
|
|
{:else if audioStore.nextStatus === 'prefetching'}
|
|
<span class="flex items-center gap-1.5 text-xs text-(--color-muted)">
|
|
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
Warming up Ch. {nextChapter}…
|
|
</span>
|
|
{:else if audioStore.nextStatus === 'prefetched'}
|
|
<span class="flex items-center gap-1.5 text-xs text-(--color-brand)">
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
|
|
</svg>
|
|
Ch. {nextChapter} ready
|
|
</span>
|
|
{:else if audioStore.nextStatus === 'failed'}
|
|
<span class="flex items-center gap-1.5 text-xs text-red-400">
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
|
</svg>
|
|
Warm-up failed
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
|
|
{:else if audioStore.active}
|
|
<!-- ── A different chapter is currently playing ── -->
|
|
<div class="flex items-center justify-between gap-3">
|
|
<p class="text-xs text-(--color-muted)">
|
|
{m.reader_now_playing({ title: audioStore.chapterTitle || `Ch.${audioStore.chapter}` })}
|
|
</p>
|
|
<Button variant="secondary" size="sm" class="flex-shrink-0" onclick={startPlayback}>
|
|
{m.reader_load_this_chapter()}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/if}
|
|
|
|
<!-- ── Chapter picker overlay ─────────────────────────────────────────────────
|
|
Rendered as a top-level sibling (outside all player containers) so that
|
|
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}
|
|
<ChapterPickerOverlay
|
|
chapters={audioStore.chapters}
|
|
activeChapter={audioStore.chapter}
|
|
zIndex="z-[60]"
|
|
onselect={playChapter}
|
|
onclose={() => { showChapterPanel = false; }}
|
|
/>
|
|
{/if}
|
|
|
|
<!-- ── Float player overlay ──────────────────────────────────────────────────
|
|
A draggable circle anchored to the viewport.
|
|
Tap = toggle play/pause.
|
|
Drag = reposition (clamped to viewport).
|
|
Visible when playerStyle='float' and audio is active for this chapter. -->
|
|
{#if playerStyle === 'float' && audioStore.isCurrentChapter(slug, chapter) && audioStore.active}
|
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
<div
|
|
class="fixed z-[55] select-none"
|
|
style="
|
|
bottom: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.y}px);
|
|
right: calc({FLOAT_MARGIN}px + {-audioStore.floatPos.x}px);
|
|
touch-action: none;
|
|
width: {FLOAT_SIZE}px;
|
|
height: {FLOAT_SIZE}px;
|
|
"
|
|
onpointerdown={onFloatPointerDown}
|
|
onpointermove={onFloatPointerMove}
|
|
onpointerup={onFloatPointerUp}
|
|
onpointercancel={(e) => { floatDragging = false; try { (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); } catch { /* ignore */ } }}
|
|
>
|
|
<!-- Pulsing ring when playing -->
|
|
{#if audioStore.isPlaying}
|
|
<span class="absolute inset-0 rounded-full bg-(--color-brand)/30 animate-ping pointer-events-none"></span>
|
|
{/if}
|
|
|
|
<!-- Circle button -->
|
|
<div
|
|
class="absolute inset-0 rounded-full bg-(--color-brand) shadow-xl flex items-center justify-center {floatDragging ? 'cursor-grabbing' : 'cursor-grab'} transition-transform active:scale-95"
|
|
>
|
|
{#if audioStore.status === 'generating' || audioStore.status === 'loading'}
|
|
<!-- Spinner -->
|
|
<svg class="w-6 h-6 text-white animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
{:else if audioStore.isPlaying}
|
|
<!-- Pause icon -->
|
|
<svg class="w-6 h-6 text-white pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
|
</svg>
|
|
{:else}
|
|
<!-- Play icon -->
|
|
<svg class="w-6 h-6 text-white ml-0.5 pointer-events-none" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Progress arc ring (thin, overlaid on circle edge) -->
|
|
{#if audioStore.duration > 0}
|
|
{@const r = 26}
|
|
{@const circ = 2 * Math.PI * r}
|
|
{@const dash = (audioStore.currentTime / audioStore.duration) * circ}
|
|
<svg
|
|
class="absolute inset-0 pointer-events-none -rotate-90"
|
|
width={FLOAT_SIZE}
|
|
height={FLOAT_SIZE}
|
|
viewBox="0 0 {FLOAT_SIZE} {FLOAT_SIZE}"
|
|
>
|
|
<circle
|
|
cx={FLOAT_SIZE / 2}
|
|
cy={FLOAT_SIZE / 2}
|
|
r={r}
|
|
fill="none"
|
|
stroke="rgba(255,255,255,0.25)"
|
|
stroke-width="2.5"
|
|
/>
|
|
<circle
|
|
cx={FLOAT_SIZE / 2}
|
|
cy={FLOAT_SIZE / 2}
|
|
r={r}
|
|
fill="none"
|
|
stroke="white"
|
|
stroke-width="2.5"
|
|
stroke-linecap="round"
|
|
stroke-dasharray="{circ}"
|
|
stroke-dashoffset="{circ - dash}"
|
|
style="transition: stroke-dashoffset 0.5s linear;"
|
|
/>
|
|
</svg>
|
|
{/if}
|
|
</div>
|
|
{/if}
|