chore: migrate to v3 and adopt Doppler for secrets management #3

Open
kamil wants to merge 574 commits from v3-cleanup into main
3 changed files with 274 additions and 45 deletions
Showing only changes of commit 0402c408e4 - Show all commits

View File

@@ -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 (0100) 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();

View File

@@ -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}

View File

@@ -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">