- Fix speed/voice bug: AudioPlayer no longer accepts speed/voice as props;
startPlayback() reads audioStore.voice/speed directly instead of overwriting them
- Add GET /api/voices endpoint (Go) proxying Kokoro, cached in-memory
- Add POST /api/audio/voice-samples endpoint (Go) to pre-generate sample clips
for all voices and store them in MinIO under _voice-samples/{voice}.mp3
- Add GET /api/presign/voice-sample/{voice} endpoint (Go)
- Add SvelteKit proxy routes: /api/voices, /api/presign/voice-sample, /api/audio/voice-samples
- Add presignVoiceSample() helper in minio.ts with proper host rewrite
- Pass book.cover through +page.server.ts -> +page.svelte -> AudioPlayer
- Set navigator.mediaSession.metadata on playback start so cover art,
book title, and chapter title appear on phone lock screen / notification
782 lines
28 KiB
Svelte
782 lines
28 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;
|
|
/** List of available voices from the Kokoro API. */
|
|
voices?: string[];
|
|
}
|
|
|
|
let {
|
|
slug,
|
|
chapter,
|
|
chapterTitle = '',
|
|
bookTitle = '',
|
|
cover = '',
|
|
nextChapter = null,
|
|
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,
|
|
targetSpeed: number
|
|
): Promise<string | null> {
|
|
const params = new URLSearchParams({
|
|
slug: targetSlug,
|
|
n: String(targetChapter),
|
|
voice: targetVoice,
|
|
speed: String(targetSpeed)
|
|
});
|
|
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;
|
|
}
|
|
|
|
// ── 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;
|
|
const speed = audioStore.speed;
|
|
|
|
audioStore.nextStatus = 'prefetching';
|
|
audioStore.nextChapterPrefetched = nextChapter;
|
|
startNextProgress();
|
|
|
|
try {
|
|
// Fast path: already generated
|
|
const url = await tryPresign(slug, nextChapter, voice, speed);
|
|
if (url) {
|
|
stopNextProgress();
|
|
audioStore.nextProgress = 100;
|
|
audioStore.nextAudioUrl = url;
|
|
audioStore.nextStatus = 'prefetched';
|
|
return;
|
|
}
|
|
|
|
// Slow path: trigger Kokoro generation in background
|
|
const res = await fetch(`/api/audio/${slug}/${nextChapter}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice, speed })
|
|
});
|
|
if (!res.ok) throw new Error(`Prefetch generation failed: HTTP ${res.status}`);
|
|
|
|
stopNextProgress();
|
|
audioStore.nextProgress = 100;
|
|
|
|
const url2 = await tryPresign(slug, nextChapter, voice, speed);
|
|
if (!url2) throw new Error('Prefetch: audio generated but presign returned 404');
|
|
|
|
audioStore.nextAudioUrl = url2;
|
|
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;
|
|
const speed = audioStore.speed;
|
|
|
|
// Populate store metadata so layout + mini-bar have track info.
|
|
audioStore.slug = slug;
|
|
audioStore.chapter = chapter;
|
|
audioStore.chapterTitle = chapterTitle;
|
|
audioStore.bookTitle = bookTitle;
|
|
|
|
// 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, speed);
|
|
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.
|
|
audioStore.status = 'generating';
|
|
startProgress();
|
|
|
|
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ voice, speed })
|
|
});
|
|
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
|
|
|
await finishProgress();
|
|
|
|
const url2 = await tryPresign(slug, chapter, voice, speed);
|
|
if (!url2) throw new Error('Audio generated but presign returned 404');
|
|
audioStore.audioUrl = url2;
|
|
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>
|