chore: migrate to v3 and adopt Doppler for secrets management #3
@@ -8,9 +8,32 @@
|
||||
*
|
||||
* Uses Svelte 5 runes ($state / $derived) — import only from .svelte files
|
||||
* or other .svelte.ts files.
|
||||
*
|
||||
* ── State machine ────────────────────────────────────────────────────────────
|
||||
*
|
||||
* Current chapter (status):
|
||||
* idle → loading → ready (fast path: audio exists in MinIO)
|
||||
* idle → loading → generating → ready (slow path: Kokoro TTS)
|
||||
* any → error
|
||||
*
|
||||
* Next chapter pre-fetch (nextStatus):
|
||||
* 'none' – no next chapter, or auto-next is off
|
||||
* 'prefetching' – POST /api/audio running for the next chapter
|
||||
* 'prefetched' – next chapter audio is ready in MinIO
|
||||
* 'failed' – pre-generation failed (will retry on navigate)
|
||||
*
|
||||
* Auto-next transition:
|
||||
* onended fires → navigate to next chapter URL
|
||||
* ↳ new chapter page mounts
|
||||
* • if nextStatus === 'prefetched' → presign + play immediately
|
||||
* • else → normal startPlayback() flow
|
||||
*
|
||||
* Pre-fetch is triggered when currentTime / duration >= 0.9 (90% mark).
|
||||
* It only runs once per chapter (guarded by nextStatus !== 'none').
|
||||
*/
|
||||
|
||||
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
||||
|
||||
class AudioStore {
|
||||
// ── What is loaded ──────────────────────────────────────────────────────
|
||||
@@ -56,6 +79,8 @@ class AudioStore {
|
||||
/**
|
||||
* The next chapter number for the currently playing chapter, or null if
|
||||
* there is no next chapter. Written by the chapter page's AudioPlayer.
|
||||
* Stored here (not cleared on unmount) so onended can still read it after
|
||||
* the component unmounts due to {#key} re-render on navigation.
|
||||
*/
|
||||
nextChapter = $state<number | null>(null);
|
||||
|
||||
@@ -66,6 +91,28 @@ class AudioStore {
|
||||
*/
|
||||
autoStartPending = $state(false);
|
||||
|
||||
// ── Next-chapter pre-fetch state ─────────────────────────────────────────
|
||||
/**
|
||||
* State of the background pre-generation for the next chapter.
|
||||
* 'none' – nothing started (default / no next chapter)
|
||||
* 'prefetching' – currently running POST /api/audio for next chapter
|
||||
* 'prefetched' – next chapter audio confirmed ready in MinIO
|
||||
* 'failed' – pre-generation failed (fallback: generate on navigate)
|
||||
*/
|
||||
nextStatus = $state<NextStatus>('none');
|
||||
|
||||
/**
|
||||
* The presigned URL obtained during pre-fetch. When the user navigates
|
||||
* to the next chapter, AudioPlayer picks this up and skips straight to play.
|
||||
*/
|
||||
nextAudioUrl = $state('');
|
||||
|
||||
/** Progress value (0–100) shown while pre-generating the next chapter. */
|
||||
nextProgress = $state(0);
|
||||
|
||||
/** Which chapter number the pre-fetch state above belongs to. */
|
||||
nextChapterPrefetched = $state<number | null>(null);
|
||||
|
||||
/** Whether the mini-bar at the bottom is visible */
|
||||
get active(): boolean {
|
||||
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
||||
@@ -75,6 +122,14 @@ class AudioStore {
|
||||
isCurrentChapter(slug: string, chapter: number): boolean {
|
||||
return this.slug === slug && this.chapter === chapter;
|
||||
}
|
||||
|
||||
/** Reset all next-chapter pre-fetch state. */
|
||||
resetNextPrefetch() {
|
||||
this.nextStatus = 'none';
|
||||
this.nextAudioUrl = '';
|
||||
this.nextProgress = 0;
|
||||
this.nextChapterPrefetched = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const audioStore = new AudioStore();
|
||||
|
||||
@@ -6,19 +6,27 @@
|
||||
* which is shared with the layout's persistent <audio> element so audio
|
||||
* survives SvelteKit navigations.
|
||||
*
|
||||
* On "Play narration" click:
|
||||
* ── Play flow ────────────────────────────────────────────────────────────
|
||||
* On "Play narration" click / auto-start:
|
||||
* 1. Populate store metadata (slug, chapter, titles, voice, speed).
|
||||
* 2. Try GET /api/presign/audio — if 200, set audioUrl → layout plays.
|
||||
* 3. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar
|
||||
* via audioStore.progress. On success, presign again and set audioUrl.
|
||||
* 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.
|
||||
*
|
||||
* When the same chapter is already loaded/playing: show inline playback state.
|
||||
* When a different chapter is playing: show "Now playing Ch.X" with a
|
||||
* "Load this chapter" button.
|
||||
* ── Pre-fetch at 90% ─────────────────────────────────────────────────────
|
||||
* A $derived watches currentTime/duration. When >= 90% and autoNext is on
|
||||
* and there IS a next chapter, it kicks off 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: when audioStore.autoNext is true, the layout navigates to the
|
||||
* next chapter on track end and sets autoStartPending = true. This component
|
||||
* detects that flag on mount and auto-starts playback.
|
||||
* ── 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';
|
||||
@@ -45,14 +53,11 @@
|
||||
}: Props = $props();
|
||||
|
||||
// Keep nextChapter in the store so the layout's onended can navigate.
|
||||
// Run as an effect so it stays in sync if the prop ever changes.
|
||||
// On unmount, clear it so a stale value can't trigger navigation after
|
||||
// the user leaves the chapter page.
|
||||
// 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;
|
||||
return () => {
|
||||
audioStore.nextChapter = null;
|
||||
};
|
||||
});
|
||||
|
||||
// Auto-start: if the layout navigated here via auto-next, kick off playback.
|
||||
@@ -63,6 +68,40 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Reset next-chapter prefetch state when this chapter changes (new page).
|
||||
// We only reset if the prefetch is for a *different* chapter than nextChapter
|
||||
// (i.e. stale data from a prior page).
|
||||
$effect(() => {
|
||||
const prefetchedFor = audioStore.nextChapterPrefetched;
|
||||
if (prefetchedFor !== null && prefetchedFor !== (nextChapter ?? null)) {
|
||||
audioStore.resetNextPrefetch();
|
||||
}
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
|
||||
@@ -107,14 +146,50 @@
|
||||
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(): Promise<string | null> {
|
||||
async function tryPresign(
|
||||
targetSlug: string,
|
||||
targetChapter: number,
|
||||
targetVoice: string,
|
||||
targetSpeed: number
|
||||
): Promise<string | null> {
|
||||
const params = new URLSearchParams({
|
||||
slug,
|
||||
n: String(chapter),
|
||||
voice,
|
||||
speed: String(speed)
|
||||
slug: targetSlug,
|
||||
n: String(targetChapter),
|
||||
voice: targetVoice,
|
||||
speed: String(targetSpeed)
|
||||
});
|
||||
const res = await fetch(`/api/presign/audio?${params}`);
|
||||
if (res.status === 404) return null;
|
||||
@@ -123,6 +198,49 @@
|
||||
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
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Core play flow ─────────────────────────────────────────────────────────
|
||||
|
||||
async function startPlayback() {
|
||||
@@ -133,13 +251,28 @@
|
||||
audioStore.bookTitle = bookTitle;
|
||||
audioStore.voice = voice;
|
||||
audioStore.speed = speed;
|
||||
// nextChapter is kept in sync by the $effect above — no need to write it here
|
||||
|
||||
audioStore.status = 'loading';
|
||||
audioStore.errorMsg = '';
|
||||
|
||||
try {
|
||||
// Fast path: audio already in MinIO.
|
||||
const url = await tryPresign();
|
||||
// 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
|
||||
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';
|
||||
@@ -161,7 +294,7 @@
|
||||
|
||||
await finishProgress();
|
||||
|
||||
const url2 = await tryPresign();
|
||||
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';
|
||||
@@ -303,6 +436,30 @@
|
||||
</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}
|
||||
|
||||
@@ -179,11 +179,16 @@
|
||||
audioStore.isPlaying = false;
|
||||
saveAudioTime();
|
||||
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
||||
audioStore.autoStartPending = true;
|
||||
goto(`/books/${audioStore.slug}/chapters/${audioStore.nextChapter}`).catch(() => {
|
||||
audioStore.autoStartPending = false;
|
||||
});
|
||||
}
|
||||
// Capture values synchronously before any async work — the AudioPlayer
|
||||
// component will unmount during navigation, but we've already read what
|
||||
// we need.
|
||||
const targetSlug = audioStore.slug;
|
||||
const targetChapter = audioStore.nextChapter;
|
||||
audioStore.autoStartPending = true;
|
||||
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
|
||||
audioStore.autoStartPending = false;
|
||||
});
|
||||
}
|
||||
}}
|
||||
preload="metadata"
|
||||
style="display:none"
|
||||
@@ -341,21 +346,33 @@
|
||||
{audioStore.speed}×
|
||||
</button>
|
||||
|
||||
<!-- Auto-next toggle -->
|
||||
<button
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class="p-1.5 rounded flex-shrink-0 transition-colors {audioStore.autoNext
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'}"
|
||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
||||
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<!-- "skip to end" / auto-advance icon -->
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Auto-next toggle (with prefetch indicator) -->
|
||||
<button
|
||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||
class="relative p-1.5 rounded flex-shrink-0 transition-colors {audioStore.autoNext
|
||||
? 'text-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'}"
|
||||
title={audioStore.autoNext
|
||||
? audioStore.nextStatus === 'prefetched'
|
||||
? `Auto-next on — Ch.${audioStore.nextChapter} ready`
|
||||
: audioStore.nextStatus === 'prefetching'
|
||||
? `Auto-next on — preparing Ch.${audioStore.nextChapter}…`
|
||||
: 'Auto-next on'
|
||||
: 'Auto-next off'}
|
||||
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
||||
aria-pressed={audioStore.autoNext}
|
||||
>
|
||||
<!-- "skip to end" / auto-advance icon -->
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
||||
</svg>
|
||||
<!-- Prefetch status dot -->
|
||||
{#if audioStore.autoNext && audioStore.nextStatus === 'prefetching'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
|
||||
{:else if audioStore.autoNext && audioStore.nextStatus === 'prefetched'}
|
||||
<span class="absolute top-0.5 right-0.5 w-1.5 h-1.5 rounded-full bg-green-400"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{:else if audioStore.status === 'generating'}
|
||||
<!-- Spinner during generation -->
|
||||
<svg class="w-6 h-6 text-amber-400 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user