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:
@@ -8,9 +8,32 @@
|
|||||||
*
|
*
|
||||||
* Uses Svelte 5 runes ($state / $derived) — import only from .svelte files
|
* Uses Svelte 5 runes ($state / $derived) — import only from .svelte files
|
||||||
* or other .svelte.ts 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 AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||||
|
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
||||||
|
|
||||||
class AudioStore {
|
class AudioStore {
|
||||||
// ── What is loaded ──────────────────────────────────────────────────────
|
// ── What is loaded ──────────────────────────────────────────────────────
|
||||||
@@ -56,6 +79,8 @@ class AudioStore {
|
|||||||
/**
|
/**
|
||||||
* The next chapter number for the currently playing chapter, or null if
|
* The next chapter number for the currently playing chapter, or null if
|
||||||
* there is no next chapter. Written by the chapter page's AudioPlayer.
|
* 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);
|
nextChapter = $state<number | null>(null);
|
||||||
|
|
||||||
@@ -66,6 +91,28 @@ class AudioStore {
|
|||||||
*/
|
*/
|
||||||
autoStartPending = $state(false);
|
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 */
|
/** Whether the mini-bar at the bottom is visible */
|
||||||
get active(): boolean {
|
get active(): boolean {
|
||||||
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
return this.status === 'ready' || this.status === 'generating' || this.status === 'loading';
|
||||||
@@ -75,6 +122,14 @@ class AudioStore {
|
|||||||
isCurrentChapter(slug: string, chapter: number): boolean {
|
isCurrentChapter(slug: string, chapter: number): boolean {
|
||||||
return this.slug === slug && this.chapter === chapter;
|
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();
|
export const audioStore = new AudioStore();
|
||||||
|
|||||||
@@ -6,19 +6,27 @@
|
|||||||
* which is shared with the layout's persistent <audio> element so audio
|
* which is shared with the layout's persistent <audio> element so audio
|
||||||
* survives SvelteKit navigations.
|
* survives SvelteKit navigations.
|
||||||
*
|
*
|
||||||
* On "Play narration" click:
|
* ── Play flow ────────────────────────────────────────────────────────────
|
||||||
|
* On "Play narration" click / auto-start:
|
||||||
* 1. Populate store metadata (slug, chapter, titles, voice, speed).
|
* 1. Populate store metadata (slug, chapter, titles, voice, speed).
|
||||||
* 2. Try GET /api/presign/audio — if 200, set audioUrl → layout plays.
|
* 2. If the pre-fetch already landed (nextStatus='prefetched' AND
|
||||||
* 3. If 404, POST /api/audio/:slug/:n to generate. Drive pseudo progress bar
|
* nextChapterPrefetched === chapter), use the cached URL immediately.
|
||||||
* via audioStore.progress. On success, presign again and set audioUrl.
|
* 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.
|
* ── Pre-fetch at 90% ─────────────────────────────────────────────────────
|
||||||
* When a different chapter is playing: show "Now playing Ch.X" with a
|
* A $derived watches currentTime/duration. When >= 90% and autoNext is on
|
||||||
* "Load this chapter" button.
|
* 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
|
* ── Auto-next ────────────────────────────────────────────────────────────
|
||||||
* next chapter on track end and sets autoStartPending = true. This component
|
* layout.svelte onended → sets autoStartPending=true → navigates.
|
||||||
* detects that flag on mount and auto-starts playback.
|
* New chapter's AudioPlayer mounts → sees autoStartPending → startPlayback()
|
||||||
|
* which uses the prefetched URL if available.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { audioStore } from '$lib/audio.svelte';
|
import { audioStore } from '$lib/audio.svelte';
|
||||||
@@ -45,14 +53,11 @@
|
|||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// Keep nextChapter in the store so the layout's onended can navigate.
|
// 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.
|
// NOTE: we do NOT clear on unmount here — the store retains the value so
|
||||||
// On unmount, clear it so a stale value can't trigger navigation after
|
// onended (which may fire after {#key} unmounts this component) can still
|
||||||
// the user leaves the chapter page.
|
// read it. The value is superseded when the new chapter mounts.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
audioStore.nextChapter = nextChapter ?? null;
|
audioStore.nextChapter = nextChapter ?? null;
|
||||||
return () => {
|
|
||||||
audioStore.nextChapter = null;
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-start: if the layout navigated here via auto-next, kick off playback.
|
// 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 ────────────────────────────────────────────────
|
// ── Pseudo progress helpers ────────────────────────────────────────────────
|
||||||
let progressRafId = 0;
|
let progressRafId = 0;
|
||||||
|
|
||||||
@@ -107,14 +146,50 @@
|
|||||||
stopProgress();
|
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 ────────────────────────────────────────────────────────────
|
// ── 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({
|
const params = new URLSearchParams({
|
||||||
slug,
|
slug: targetSlug,
|
||||||
n: String(chapter),
|
n: String(targetChapter),
|
||||||
voice,
|
voice: targetVoice,
|
||||||
speed: String(speed)
|
speed: String(targetSpeed)
|
||||||
});
|
});
|
||||||
const res = await fetch(`/api/presign/audio?${params}`);
|
const res = await fetch(`/api/presign/audio?${params}`);
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
@@ -123,6 +198,49 @@
|
|||||||
return data.url;
|
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 ─────────────────────────────────────────────────────────
|
// ── Core play flow ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function startPlayback() {
|
async function startPlayback() {
|
||||||
@@ -133,13 +251,28 @@
|
|||||||
audioStore.bookTitle = bookTitle;
|
audioStore.bookTitle = bookTitle;
|
||||||
audioStore.voice = voice;
|
audioStore.voice = voice;
|
||||||
audioStore.speed = speed;
|
audioStore.speed = speed;
|
||||||
// nextChapter is kept in sync by the $effect above — no need to write it here
|
|
||||||
audioStore.status = 'loading';
|
audioStore.status = 'loading';
|
||||||
audioStore.errorMsg = '';
|
audioStore.errorMsg = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fast path: audio already in MinIO.
|
// Fast path A: pre-fetch already landed for THIS chapter.
|
||||||
const url = await tryPresign();
|
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) {
|
if (url) {
|
||||||
audioStore.audioUrl = url;
|
audioStore.audioUrl = url;
|
||||||
audioStore.status = 'ready';
|
audioStore.status = 'ready';
|
||||||
@@ -161,7 +294,7 @@
|
|||||||
|
|
||||||
await finishProgress();
|
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');
|
if (!url2) throw new Error('Audio generated but presign returned 404');
|
||||||
audioStore.audioUrl = url2;
|
audioStore.audioUrl = url2;
|
||||||
audioStore.status = 'ready';
|
audioStore.status = 'ready';
|
||||||
@@ -303,6 +436,30 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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}
|
{/if}
|
||||||
|
|
||||||
{:else if audioStore.active}
|
{:else if audioStore.active}
|
||||||
|
|||||||
@@ -179,11 +179,16 @@
|
|||||||
audioStore.isPlaying = false;
|
audioStore.isPlaying = false;
|
||||||
saveAudioTime();
|
saveAudioTime();
|
||||||
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
||||||
audioStore.autoStartPending = true;
|
// Capture values synchronously before any async work — the AudioPlayer
|
||||||
goto(`/books/${audioStore.slug}/chapters/${audioStore.nextChapter}`).catch(() => {
|
// component will unmount during navigation, but we've already read what
|
||||||
audioStore.autoStartPending = false;
|
// we need.
|
||||||
});
|
const targetSlug = audioStore.slug;
|
||||||
}
|
const targetChapter = audioStore.nextChapter;
|
||||||
|
audioStore.autoStartPending = true;
|
||||||
|
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
|
||||||
|
audioStore.autoStartPending = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
style="display:none"
|
style="display:none"
|
||||||
@@ -341,21 +346,33 @@
|
|||||||
{audioStore.speed}×
|
{audioStore.speed}×
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Auto-next toggle -->
|
<!-- Auto-next toggle (with prefetch indicator) -->
|
||||||
<button
|
<button
|
||||||
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
onclick={() => (audioStore.autoNext = !audioStore.autoNext)}
|
||||||
class="p-1.5 rounded flex-shrink-0 transition-colors {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-amber-400 bg-amber-400/15 hover:bg-amber-400/25'
|
||||||
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'}"
|
: 'text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800'}"
|
||||||
title={audioStore.autoNext ? 'Auto-next on' : 'Auto-next off'}
|
title={audioStore.autoNext
|
||||||
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
? audioStore.nextStatus === 'prefetched'
|
||||||
aria-pressed={audioStore.autoNext}
|
? `Auto-next on — Ch.${audioStore.nextChapter} ready`
|
||||||
>
|
: audioStore.nextStatus === 'prefetching'
|
||||||
<!-- "skip to end" / auto-advance icon -->
|
? `Auto-next on — preparing Ch.${audioStore.nextChapter}…`
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
: 'Auto-next on'
|
||||||
<path d="M6 18l8.5-6L6 6v12zm8.5-6L23 6v12l-8.5-6z"/>
|
: 'Auto-next off'}
|
||||||
</svg>
|
aria-label="Auto-next {audioStore.autoNext ? 'on' : 'off'}"
|
||||||
</button>
|
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'}
|
{:else if audioStore.status === 'generating'}
|
||||||
<!-- Spinner during generation -->
|
<!-- Spinner during generation -->
|
||||||
<svg class="w-6 h-6 text-amber-400 animate-spin flex-shrink-0" fill="none" viewBox="0 0 24 24">
|
<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