feat(ui): pre-fetch next chapter audio at 90% playback progress

- Add NextStatus type and prefetch state (nextStatus, nextAudioUrl, nextProgress, nextChapterPrefetched) to AudioStore
- AudioPlayer triggers prefetchNext() when currentTime/duration >= 0.9, autoNext is on, and a next chapter exists
- startPlayback() uses the pre-fetched URL if available (skipping presign round-trip and generation wait)
- Auto-next button in layout shows a pulsing dot while prefetching and a green dot when ready
- AudioPlayer shows inline prefetch progress and ready state below the controls
- Fix indentation regression in layout onended handler
This commit is contained in:
Admin
2026-03-04 17:09:51 +05:00
parent d14644238f
commit 0402c408e4
3 changed files with 274 additions and 45 deletions

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}