Files
libnovel/ui/src/lib/audio.svelte.ts
Admin 06d4a7bfd4
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
feat: profile stats, discover history, end-of-chapter sleep, rating-ranked deck
**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>
2026-04-03 07:26:54 +05:00

154 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 0100 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 (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';
}
/** 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();