All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 46s
Release / Docker / caddy (push) Successful in 52s
Release / Docker / backend (push) Successful in 2m32s
Release / Docker / ui (push) Successful in 2m15s
Release / Docker / runner (push) Successful in 2m50s
Release / Gitea Release (push) Successful in 22s
**Profile stats tab** - New Stats tab on /profile page (Profile / Stats switcher) - Reading overview: chapters read, completed, reading, plan-to-read counts - Activity cards: day streak + avg rating given - Favourite genres (top 3 by frequency across library/progress) - getUserStats() in pocketbase.ts — computes streak, shelf counts, genre freq **Discover history tab** - New History tab on /discover with full voted-book list - Per-entry: cover thumbnail, title link, author, action label (Liked/Skipped/etc.) - Undo button: optimistic update + DELETE /api/discover/vote?slug=... - Clear all history button; tab shows vote count badge - getVotedBooks(), undoDiscoveryVote() in pocketbase.ts **Rating-ranked discovery deck** - getBooksForDiscovery now sorts by community avg rating before returning - Tier-based shuffle: books within the same ±0.5 star bucket are still randomised - Higher-rated books surface earlier without making the deck fully deterministic **End-of-chapter sleep timer** - New cycle option: Off → End of Chapter → 15m → 30m → 45m → 60m → Off - sleepAfterChapter flag in AudioStore; layout handles it in onended (skips auto-next) - Button shows "End Ch." label when active in this mode Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
154 lines
6.3 KiB
TypeScript
154 lines
6.3 KiB
TypeScript
/**
|
||
* Global audio player state for libnovel.
|
||
*
|
||
* A single shared instance (module singleton) keeps audio playing across
|
||
* SvelteKit navigations. The layout mounts the <audio> element once and
|
||
* never unmounts it; the per-chapter AudioPlayer component is just a
|
||
* controller that reads/writes this state.
|
||
*
|
||
* 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 ──────────────────────────────────────────────────────
|
||
slug = $state('');
|
||
chapter = $state(0);
|
||
chapterTitle = $state('');
|
||
bookTitle = $state('');
|
||
voice = $state('af_bella');
|
||
speed = $state(1.0);
|
||
|
||
/** Cover image URL for the currently loaded book. */
|
||
cover = $state('');
|
||
|
||
/** Full chapter list for the currently loaded book (number + title). */
|
||
chapters = $state<{ number: number; title: string }[]>([]);
|
||
|
||
// ── Loading/generation state ────────────────────────────────────────────
|
||
status = $state<AudioStatus>('idle');
|
||
audioUrl = $state('');
|
||
errorMsg = $state('');
|
||
/** Pseudo-progress bar value 0–100 during generation */
|
||
progress = $state(0);
|
||
|
||
// ── Playback state (kept in sync with the <audio> element) ─────────────
|
||
currentTime = $state(0);
|
||
duration = $state(0);
|
||
isPlaying = $state(false);
|
||
|
||
/**
|
||
* Increment to signal the layout to toggle play/pause.
|
||
* The layout watches this with $effect and calls audioEl.play()/pause().
|
||
*/
|
||
toggleRequest = $state(0);
|
||
|
||
/**
|
||
* Set to a number to seek the audio element to that time (seconds).
|
||
* The layout watches this with $effect and sets audioEl.currentTime.
|
||
* Reset to null after handling.
|
||
*/
|
||
seekRequest = $state<number | null>(null);
|
||
|
||
// ── Sleep timer ──────────────────────────────────────────────────────────
|
||
/** Epoch ms when sleep timer should fire. 0 = off. */
|
||
sleepUntil = $state(0);
|
||
|
||
/** When true, pause after the current chapter ends instead of navigating. */
|
||
sleepAfterChapter = $state(false);
|
||
|
||
// ── Auto-next ────────────────────────────────────────────────────────────
|
||
/**
|
||
* When true, navigates to the next chapter when the current one ends
|
||
* and auto-starts its audio.
|
||
*/
|
||
autoNext = $state(false);
|
||
|
||
/**
|
||
* 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);
|
||
|
||
/**
|
||
* Set to the chapter number that should auto-start by the layout's onended
|
||
* handler (when autoNext fires a navigation). The AudioPlayer on the new
|
||
* page checks this on mount: if it matches the component's own chapter prop
|
||
* it starts playback and clears the value.
|
||
*
|
||
* Using the target chapter number (instead of a plain boolean) prevents the
|
||
* still-mounted outgoing AudioPlayer from reacting to the flag before the
|
||
* navigation completes — it only matches the incoming chapter's component.
|
||
*/
|
||
autoStartChapter = $state<number | null>(null);
|
||
|
||
// ── 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';
|
||
}
|
||
|
||
/** True when the currently loaded track matches slug+chapter */
|
||
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();
|