Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 1s
CI / UI / Build (pull_request) Failing after 7s
CI / Scraper / Lint (pull_request) Failing after 8s
CI / Scraper / Test (pull_request) Failing after 9s
CI / Scraper / Build (pull_request) Has been skipped
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped
Replace blocking POST /api/audio with a non-blocking 202 flow: the Go
handler immediately enqueues a job in a new `audio_jobs` PocketBase
collection and returns {job_id, status}. A background goroutine runs
the actual Kokoro TTS work and updates job status (pending → generating
→ done/failed). A new GET /api/audio/status/{slug}/{n} endpoint lets
clients poll progress. The SvelteKit proxy and AudioPlayer.svelte are
updated to POST, then poll the status route every 2s until done.
867 lines
31 KiB
Svelte
867 lines
31 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, presign again and set audioUrl.
|
|
*
|
|
* ── 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';
|
|
|
|
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 Kokoro API. */
|
|
voices?: string[];
|
|
}
|
|
|
|
let {
|
|
slug,
|
|
chapter,
|
|
chapterTitle = '',
|
|
bookTitle = '',
|
|
cover = '',
|
|
nextChapter = null,
|
|
chapters = [],
|
|
voices = []
|
|
}: Props = $props();
|
|
|
|
// ── 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);
|
|
|
|
/**
|
|
* Human-readable label for a voice ID.
|
|
* e.g. "af_bella" → "Bella (US F)" | "bm_george" → "George (UK M)"
|
|
*/
|
|
function voiceLabel(v: 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 = v.slice(0, 2);
|
|
const name = v.slice(3);
|
|
// Capitalise and strip legacy v0 prefix.
|
|
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.
|
|
// NOTE: we do NOT clear on unmount here — the store retains the value so
|
|
// onended (which may fire after {#key} unmounts this component) can still
|
|
// read it. The value is superseded when the new chapter mounts.
|
|
$effect(() => {
|
|
audioStore.nextChapter = nextChapter ?? null;
|
|
});
|
|
|
|
// 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 voice panel when user clicks outside (escape key).
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
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 ────────────────────────────────────────────────────────────
|
|
|
|
async function tryPresign(
|
|
targetSlug: string,
|
|
targetChapter: number,
|
|
targetVoice: string
|
|
): Promise<string | null> {
|
|
const params = new URLSearchParams({
|
|
slug: targetSlug,
|
|
n: String(targetChapter),
|
|
voice: targetVoice
|
|
});
|
|
const res = await fetch(`/api/presign/audio?${params}`);
|
|
if (res.status === 404) return null;
|
|
if (!res.ok) throw new Error(`presign HTTP ${res.status}`);
|
|
const data = (await res.json()) as { url: string };
|
|
return data.url;
|
|
}
|
|
|
|
type AudioStatusResponse =
|
|
| { status: 'done'; url: string; filename: string }
|
|
| { 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 url = await tryPresign(slug, nextChapter, voice);
|
|
if (url) {
|
|
stopNextProgress();
|
|
audioStore.nextProgress = 100;
|
|
audioStore.nextAudioUrl = url;
|
|
audioStore.nextStatus = 'prefetched';
|
|
return;
|
|
}
|
|
|
|
// Slow path: trigger Kokoro generation (non-blocking POST), then poll.
|
|
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 the scraper returned cached audio immediately (200), use the url.
|
|
if (res.status === 200) {
|
|
const cached = (await res.json()) as { url: string };
|
|
stopNextProgress();
|
|
audioStore.nextProgress = 100;
|
|
audioStore.nextAudioUrl = cached.url;
|
|
audioStore.nextStatus = 'prefetched';
|
|
return;
|
|
}
|
|
|
|
// 202: poll until done.
|
|
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'}`);
|
|
}
|
|
|
|
// Use the URL from the status response, or fall back to presign.
|
|
const doneUrl =
|
|
(final as { url?: string }).url ?? (await tryPresign(slug, nextChapter, voice));
|
|
if (!doneUrl) throw new Error('Prefetch: audio done but no URL available');
|
|
|
|
audioStore.nextAudioUrl = doneUrl;
|
|
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;
|
|
|
|
// Update OS media session (lock screen / notification center).
|
|
setMediaSession();
|
|
|
|
audioStore.status = 'loading';
|
|
audioStore.errorMsg = '';
|
|
|
|
try {
|
|
// Fast path A: pre-fetch already landed for THIS chapter.
|
|
if (
|
|
audioStore.nextStatus === 'prefetched' &&
|
|
audioStore.nextChapterPrefetched === chapter &&
|
|
audioStore.nextAudioUrl
|
|
) {
|
|
const url = audioStore.nextAudioUrl;
|
|
// Consume the pre-fetch — reset so it doesn't carry over
|
|
audioStore.resetNextPrefetch();
|
|
audioStore.audioUrl = 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;
|
|
}
|
|
|
|
// Fast path B: audio already in MinIO (presign check).
|
|
const url = await tryPresign(slug, chapter, voice);
|
|
if (url) {
|
|
audioStore.audioUrl = 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: trigger Kokoro generation (non-blocking POST), then poll.
|
|
audioStore.status = 'generating';
|
|
startProgress();
|
|
|
|
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice })
|
|
});
|
|
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
|
|
|
// If the scraper returned cached audio immediately (200), use the url.
|
|
if (res.status === 200) {
|
|
const cached = (await res.json()) as { url: string };
|
|
await finishProgress();
|
|
audioStore.audioUrl = cached.url;
|
|
audioStore.status = 'ready';
|
|
maybeStartPrefetch();
|
|
return;
|
|
}
|
|
|
|
// 202: poll until done.
|
|
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();
|
|
|
|
// Use the URL from the status response, or fall back to presign.
|
|
const doneUrl =
|
|
(final as { url?: string }).url ?? (await tryPresign(slug, chapter, voice));
|
|
if (!doneUrl) throw new Error('Audio generated but no URL available');
|
|
audioStore.audioUrl = doneUrl;
|
|
audioStore.status = 'ready';
|
|
// Don't restore time for freshly generated audio — position is 0
|
|
// Immediately start pre-generating the next chapter in background.
|
|
maybeStartPrefetch();
|
|
} 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();
|
|
}
|
|
|
|
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')}`;
|
|
}
|
|
</script>
|
|
|
|
<svelte:window onkeydown={handleKeyDown} />
|
|
|
|
<div class="mt-6 p-4 rounded-lg bg-zinc-800 border border-zinc-700">
|
|
<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-amber-400" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 3v10.55A4 4 0 1014 17V7h4V3h-6z"/>
|
|
</svg>
|
|
<span class="text-sm text-zinc-300 font-medium">Audio Narration</span>
|
|
</div>
|
|
|
|
<!-- Voice selector button -->
|
|
{#if voices.length > 0}
|
|
<button
|
|
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; }}
|
|
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors {showVoicePanel
|
|
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
|
: 'text-zinc-400 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
|
|
title="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="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-zinc-600 bg-zinc-900 overflow-hidden">
|
|
<div class="px-3 py-2 border-b border-zinc-700 flex items-center justify-between">
|
|
<span class="text-xs font-semibold text-zinc-400 uppercase tracking-wider">Choose Voice</span>
|
|
<button
|
|
onclick={() => { stopSample(); showVoicePanel = false; }}
|
|
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
|
aria-label="Close voice selector"
|
|
>
|
|
<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">
|
|
{#each voices as v (v)}
|
|
<div
|
|
class="flex items-center gap-2 px-3 py-2 hover:bg-zinc-800 transition-colors cursor-pointer {audioStore.voice === v ? 'bg-amber-400/10' : ''}"
|
|
role="button"
|
|
tabindex="0"
|
|
onclick={() => selectVoice(v)}
|
|
onkeydown={(e) => e.key === 'Enter' && selectVoice(v)}
|
|
>
|
|
<!-- Selected indicator -->
|
|
<div class="w-4 flex-shrink-0">
|
|
{#if audioStore.voice === v}
|
|
<svg class="w-3.5 h-3.5 text-amber-400" 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="flex-1 text-xs {audioStore.voice === v ? 'text-amber-400 font-medium' : 'text-zinc-300'}">
|
|
{voiceLabel(v)}
|
|
</span>
|
|
<span class="text-zinc-600 text-xs font-mono">{v}</span>
|
|
|
|
<!-- Sample play button (stop propagation so click doesn't select) -->
|
|
<button
|
|
onclick={(e) => { e.stopPropagation(); playSample(v); }}
|
|
class="p-1 rounded transition-colors flex-shrink-0 {samplePlayingVoice === v
|
|
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
|
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-700'}"
|
|
title={samplePlayingVoice === v ? 'Stop sample' : 'Play sample'}
|
|
aria-label={samplePlayingVoice === v ? `Stop ${v} sample` : `Play ${v} sample`}
|
|
>
|
|
{#if samplePlayingVoice === v}
|
|
<!-- Stop icon -->
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 6h12v12H6z"/>
|
|
</svg>
|
|
{:else}
|
|
<!-- Play icon -->
|
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
<div class="px-3 py-2 border-t border-zinc-700 bg-zinc-800/50">
|
|
<p class="text-xs text-zinc-500">
|
|
New voice applies on next "Play narration".
|
|
{#if voices.length > 0}
|
|
<a
|
|
href="/api/audio/voice-samples"
|
|
class="text-zinc-400 hover:text-amber-400 transition-colors underline"
|
|
onclick={(e) => {
|
|
e.preventDefault();
|
|
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
|
}}
|
|
>Generate missing samples</a>
|
|
{/if}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if audioStore.isCurrentChapter(slug, chapter)}
|
|
<!-- ── This chapter is the active one ── -->
|
|
|
|
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
|
<!-- Should not normally reach here while current, but handle gracefully -->
|
|
{#if audioStore.status === 'error'}
|
|
<p class="text-red-400 text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
|
{/if}
|
|
<button
|
|
onclick={handlePlay}
|
|
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
|
|
>
|
|
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
Play narration
|
|
</button>
|
|
|
|
{:else if audioStore.status === 'loading'}
|
|
<button
|
|
disabled
|
|
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold opacity-50 cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
<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>
|
|
Loading…
|
|
</button>
|
|
|
|
{:else if audioStore.status === 'generating'}
|
|
<div class="space-y-2">
|
|
<p class="text-xs text-zinc-400">Generating narration…</p>
|
|
<div class="w-full h-1.5 bg-zinc-700 rounded-full overflow-hidden">
|
|
<div
|
|
class="h-full bg-amber-400 rounded-full transition-none"
|
|
style="width: {audioStore.progress}%"
|
|
></div>
|
|
</div>
|
|
<p class="text-xs text-zinc-500 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-zinc-400">
|
|
{#if audioStore.isPlaying}
|
|
<svg class="w-3.5 h-3.5 text-amber-400 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
|
</svg>
|
|
<span>Playing — controls below</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>Paused — controls below</span>
|
|
{/if}
|
|
<span class="tabular-nums text-zinc-500">
|
|
{formatTime(audioStore.currentTime)} / {formatTime(audioStore.duration)}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Auto-next toggle (keep here as useful context) -->
|
|
{#if nextChapter !== null && nextChapter !== undefined}
|
|
<button
|
|
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
|
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium transition-colors flex-shrink-0 {audioStore.autoNext
|
|
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
|
: 'text-zinc-500 bg-zinc-700 hover:bg-zinc-600 hover:text-zinc-200'}"
|
|
title={audioStore.autoNext ? `Auto-next on — will play Ch.${nextChapter} automatically` : '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>
|
|
Auto
|
|
</button>
|
|
{/if}
|
|
</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-zinc-500">
|
|
<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>Preparing Ch.{nextChapter}… {Math.round(audioStore.nextProgress)}%</span>
|
|
</div>
|
|
{:else if audioStore.nextStatus === 'prefetched'}
|
|
<p class="text-xs text-zinc-500 flex items-center gap-1">
|
|
<svg class="w-3 h-3 text-amber-400 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>
|
|
Ch.{nextChapter} ready
|
|
</p>
|
|
{:else if audioStore.nextStatus === 'failed'}
|
|
<p class="text-xs text-zinc-600">Ch.{nextChapter} will generate on navigate</p>
|
|
{/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-zinc-400">
|
|
Now playing: {audioStore.chapterTitle || `Ch.${audioStore.chapter}`}
|
|
</p>
|
|
<button
|
|
onclick={startPlayback}
|
|
class="px-3 py-1 rounded bg-zinc-700 text-zinc-200 text-xs font-medium hover:bg-zinc-600 transition-colors flex-shrink-0"
|
|
>
|
|
Load this chapter
|
|
</button>
|
|
</div>
|
|
|
|
{:else}
|
|
<!-- ── Idle — nothing playing ── -->
|
|
<button
|
|
onclick={handlePlay}
|
|
class="px-4 py-1.5 rounded bg-amber-400 text-zinc-900 text-sm font-semibold hover:bg-amber-300 transition-colors flex items-center gap-2"
|
|
>
|
|
<svg class="w-3.5 h-3.5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z"/>
|
|
</svg>
|
|
Play narration
|
|
</button>
|
|
{/if}
|
|
</div>
|