Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c33b22511 | ||
|
|
85492fae73 | ||
|
|
559b6234e7 | ||
|
|
75cac363fc | ||
|
|
68c7ae55e7 |
@@ -9,6 +9,7 @@ package backend
|
||||
// handleGetRanking, handleGetCover
|
||||
// handleBookPreview, handleChapterText, handleChapterTextPreview, handleChapterMarkdown, handleReindex
|
||||
// handleAudioGenerate, handleAudioStatus, handleAudioProxy, handleAudioStream
|
||||
// handleTTSAnnounce
|
||||
// handleVoices
|
||||
// handlePresignChapter, handlePresignAudio, handlePresignVoiceSample
|
||||
// handlePresignAvatarUpload, handlePresignAvatar
|
||||
@@ -904,7 +905,119 @@ func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) {
|
||||
// on its next poll as soon as the MinIO object is present.
|
||||
}
|
||||
|
||||
// handleAudioPreview handles GET /api/audio-preview/{slug}/{n}.
|
||||
// handleTTSAnnounce handles GET /api/tts-announce.
|
||||
//
|
||||
// Streams a short TTS clip for arbitrary text — used by the UI to announce
|
||||
// the upcoming chapter number/title through the real <audio> element instead
|
||||
// of the Web Speech API (which is silently muted on mobile after the audio
|
||||
// session ends).
|
||||
//
|
||||
// Query params:
|
||||
// - text — the text to synthesize (required, max 300 chars)
|
||||
// - voice — voice ID (defaults to server default)
|
||||
// - format — "mp3" or "wav" (default "mp3")
|
||||
//
|
||||
// No MinIO caching — announcement clips are tiny and ephemeral.
|
||||
func (s *Server) handleTTSAnnounce(w http.ResponseWriter, r *http.Request) {
|
||||
text := r.URL.Query().Get("text")
|
||||
if text == "" {
|
||||
jsonError(w, http.StatusBadRequest, "text is required")
|
||||
return
|
||||
}
|
||||
if len(text) > 300 {
|
||||
text = text[:300]
|
||||
}
|
||||
|
||||
voice := r.URL.Query().Get("voice")
|
||||
if voice == "" {
|
||||
voice = s.cfg.DefaultVoice
|
||||
}
|
||||
|
||||
format := r.URL.Query().Get("format")
|
||||
if format != "wav" {
|
||||
format = "mp3"
|
||||
}
|
||||
|
||||
contentType := "audio/mpeg"
|
||||
if format == "wav" {
|
||||
contentType = "audio/wav"
|
||||
}
|
||||
|
||||
var (
|
||||
audioStream io.ReadCloser
|
||||
err error
|
||||
)
|
||||
|
||||
if format == "wav" {
|
||||
if cfai.IsCFAIVoice(voice) {
|
||||
if s.deps.CFAI == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.CFAI.StreamAudioWAV(r.Context(), text, voice)
|
||||
} else if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioWAV(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioWAV(r.Context(), text, voice)
|
||||
}
|
||||
} else {
|
||||
if cfai.IsCFAIVoice(voice) {
|
||||
if s.deps.CFAI == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "cloudflare AI TTS not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.CFAI.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else if pockettts.IsPocketTTSVoice(voice) {
|
||||
if s.deps.PocketTTS == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "pocket-tts not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.PocketTTS.StreamAudioMP3(r.Context(), text, voice)
|
||||
} else {
|
||||
if s.deps.Kokoro == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "kokoro not configured")
|
||||
return
|
||||
}
|
||||
audioStream, err = s.deps.Kokoro.StreamAudioMP3(r.Context(), text, voice)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
s.deps.Log.Error("handleTTSAnnounce: TTS stream failed", "voice", voice, "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, "tts stream failed")
|
||||
return
|
||||
}
|
||||
defer audioStream.Close()
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
flusher, canFlush := w.(http.Flusher)
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, readErr := audioStream.Read(buf)
|
||||
if nr > 0 {
|
||||
if _, writeErr := w.Write(buf[:nr]); writeErr != nil {
|
||||
return
|
||||
}
|
||||
if canFlush {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
if readErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
//
|
||||
// CF AI voices are batch-only and can take 1-2+ minutes to generate a full
|
||||
// chapter. This endpoint generates only the FIRST chunk of text (~1 800 chars,
|
||||
|
||||
@@ -180,6 +180,8 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Streaming audio: serves from MinIO if cached, else streams live TTS
|
||||
// while simultaneously uploading to MinIO for future requests.
|
||||
mux.HandleFunc("GET /api/audio-stream/{slug}/{n}", s.handleAudioStream)
|
||||
// TTS for arbitrary short text (chapter announcements) — no MinIO caching.
|
||||
mux.HandleFunc("GET /api/tts-announce", s.handleTTSAnnounce)
|
||||
// CF AI preview: generates only the first ~1 800-char chunk so the client
|
||||
// can start playing immediately while the full audio is generated by the runner.
|
||||
mux.HandleFunc("GET /api/audio-preview/{slug}/{n}", s.handleAudioPreview)
|
||||
|
||||
@@ -55,7 +55,13 @@ service:
|
||||
extensions: [health_check, pprof]
|
||||
telemetry:
|
||||
metrics:
|
||||
address: 0.0.0.0:8888
|
||||
# otel-collector v0.103+ replaced `address` with `readers`
|
||||
readers:
|
||||
- pull:
|
||||
exporter:
|
||||
prometheus:
|
||||
host: 0.0.0.0
|
||||
port: 8888
|
||||
pipelines:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
|
||||
@@ -160,6 +160,19 @@ class AudioStore {
|
||||
return this.slug === slug && this.chapter === chapter;
|
||||
}
|
||||
|
||||
// ── Announce-chapter navigation state ────────────────────────────────────
|
||||
/**
|
||||
* When true, the <audio> element is playing a short announcement clip
|
||||
* (not chapter audio). The next `onended` should navigate to
|
||||
* announcePendingSlug / announcePendingChapter instead of the normal
|
||||
* auto-next flow.
|
||||
*/
|
||||
announceNavigatePending = $state(false);
|
||||
/** Target book slug for the pending announce-then-navigate transition. */
|
||||
announcePendingSlug = $state('');
|
||||
/** Target chapter number for the pending announce-then-navigate transition. */
|
||||
announcePendingChapter = $state(0);
|
||||
|
||||
/** Reset all next-chapter pre-fetch state. */
|
||||
resetNextPrefetch() {
|
||||
this.nextStatus = 'none';
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
onProRequired?: () => void;
|
||||
/** Visual style of the player card. 'standard' = full controls; 'compact' = slim seekable player. */
|
||||
playerStyle?: 'standard' | 'compact';
|
||||
/** Approximate word count for the chapter, used to show estimated listen time in the idle state. */
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -84,9 +86,13 @@
|
||||
chapters = [],
|
||||
voices = [],
|
||||
onProRequired = undefined,
|
||||
playerStyle = 'standard'
|
||||
playerStyle = 'standard',
|
||||
wordCount = 0
|
||||
}: Props = $props();
|
||||
|
||||
/** Estimated listen time in minutes at ~150 wpm average narration speed. */
|
||||
const estimatedMinutes = $derived(wordCount > 0 ? Math.max(1, Math.round(wordCount / 150)) : 0);
|
||||
|
||||
// ── Derived: voices grouped by engine ──────────────────────────────────
|
||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
||||
@@ -1063,6 +1069,134 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- ── Standard player ─────────────────────────────────────────────────────── -->
|
||||
|
||||
{#if
|
||||
(!audioStore.isCurrentChapter(slug, chapter) && !audioStore.active) ||
|
||||
(audioStore.isCurrentChapter(slug, chapter) && (audioStore.status === 'idle' || audioStore.status === 'error'))
|
||||
}
|
||||
<!-- ── Idle / not-yet-started pill ─────────────────────────────────────────── -->
|
||||
<div class="px-3 py-2.5">
|
||||
{#if audioStore.isCurrentChapter(slug, chapter) && audioStore.status === 'error'}
|
||||
<p class="text-(--color-danger) text-xs mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Big play button -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={handlePlay}
|
||||
class="w-11 h-11 rounded-full bg-(--color-brand) text-(--color-surface) flex items-center justify-center hover:bg-(--color-brand-dim) active:scale-95 transition-all flex-shrink-0 shadow-sm"
|
||||
aria-label={m.reader_play_narration()}
|
||||
>
|
||||
<svg class="w-5 h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Track info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-(--color-text) leading-tight truncate">
|
||||
{m.reader_play_narration()}
|
||||
</p>
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<!-- Voice indicator -->
|
||||
{#if voices.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { stopSample(); showVoicePanel = !showVoicePanel; showChapterPanel = false; chapterSearch = ''; }}
|
||||
class={cn('flex items-center gap-1 text-xs transition-colors leading-none', showVoicePanel ? 'text-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)')}
|
||||
title={m.reader_change_voice()}
|
||||
>
|
||||
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/>
|
||||
</svg>
|
||||
<span class="max-w-[90px] truncate">{voiceLabel(audioStore.voice)}</span>
|
||||
<svg class={cn('w-2.5 h-2.5 flex-shrink-0 transition-transform', showVoicePanel && 'rotate-180')} fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Estimated duration -->
|
||||
{#if estimatedMinutes > 0}
|
||||
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
|
||||
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chapters button (right side) -->
|
||||
{#if chapters.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterPanel = !showChapterPanel; showVoicePanel = false; stopSample(); }}
|
||||
class={cn('flex items-center gap-1 px-2 py-1.5 rounded-md text-xs transition-colors flex-shrink-0', showChapterPanel ? 'text-(--color-brand) bg-(--color-brand)/10' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-3)')}
|
||||
title="Browse chapters"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h10"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Chapters</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Voice selector panel (inline below pill) -->
|
||||
{#if showVoicePanel && voices.length > 0}
|
||||
<div class="mt-3 rounded-lg border border-(--color-border) bg-(--color-surface) overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-(--color-border) flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-(--color-muted) uppercase tracking-wider">{m.reader_choose_voice()}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 text-(--color-muted) hover:text-(--color-text)"
|
||||
onclick={() => { stopSample(); showVoicePanel = false; }}
|
||||
aria-label={m.reader_close_voice_panel()}
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="max-h-64 overflow-y-auto">
|
||||
{#if kokoroVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Kokoro (GPU)</span>
|
||||
</div>
|
||||
{#each kokoroVoices as v (v.id)}{@render voiceRow(v)}{/each}
|
||||
{/if}
|
||||
{#if pocketVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Pocket TTS (CPU)</span>
|
||||
</div>
|
||||
{#each pocketVoices as v (v.id)}{@render voiceRow(v)}{/each}
|
||||
{/if}
|
||||
{#if cfaiVoices.length > 0}
|
||||
<div class="px-3 py-1.5 bg-(--color-surface-2)/70 border-b border-(--color-border)/50 {kokoroVoices.length > 0 || pocketVoices.length > 0 ? 'border-t border-(--color-border)' : ''}">
|
||||
<span class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest">Cloudflare AI</span>
|
||||
</div>
|
||||
{#each cfaiVoices as v (v.id)}{@render voiceRow(v)}{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-3 py-2 border-t border-(--color-border) bg-(--color-surface-2)/50">
|
||||
<p class="text-xs text-(--color-muted)">
|
||||
{m.reader_voice_applies_next()}
|
||||
{#if voices.length > 0}
|
||||
<a
|
||||
href="/api/audio/voice-samples"
|
||||
class="text-(--color-muted) hover:text-(--color-brand) transition-colors underline"
|
||||
onclick={(e) => {
|
||||
e.preventDefault();
|
||||
fetch('/api/audio/voice-samples', { method: 'POST' }).catch(() => {});
|
||||
}}
|
||||
>{m.reader_generate_samples()}</a>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Non-idle states (loading / generating / ready / other-chapter-playing) ── -->
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-end gap-2 mb-3">
|
||||
<!-- Chapter picker button -->
|
||||
@@ -1167,22 +1301,10 @@
|
||||
{/if}
|
||||
|
||||
|
||||
|
||||
{#if audioStore.isCurrentChapter(slug, chapter)}
|
||||
<!-- ── This chapter is the active one ── -->
|
||||
<!-- ── This chapter is the active one (non-idle states) ── -->
|
||||
|
||||
{#if audioStore.status === 'idle' || audioStore.status === 'error'}
|
||||
{#if audioStore.status === 'error'}
|
||||
<p class="text-(--color-danger) text-sm mb-2">{audioStore.errorMsg || 'Failed to load audio.'}</p>
|
||||
{/if}
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
|
||||
{:else if audioStore.status === 'loading'}
|
||||
{#if audioStore.status === 'loading'}
|
||||
<Button variant="default" size="sm" disabled>
|
||||
<svg class="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
@@ -1235,15 +1357,6 @@
|
||||
{m.reader_load_this_chapter()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Idle — nothing playing ── -->
|
||||
<Button variant="default" size="sm" onclick={handlePlay}>
|
||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
{m.reader_play_narration()}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -373,11 +373,11 @@
|
||||
{#if showVoiceModal && voices.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute inset-0 z-70 flex flex-col"
|
||||
class="fixed inset-0 z-[80] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<!-- Modal header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { stopSample(); showVoiceModal = false; voiceSearch = ''; }}
|
||||
@@ -405,7 +405,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Voice list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
|
||||
{#each ([['Kokoro', filteredKokoro], ['Pocket TTS', filteredPocket], ['CF AI', filteredCfai]] as [string, Voice[]][]) as [label, group]}
|
||||
{#if group.length > 0}
|
||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-wider px-4 py-2 sticky top-0 bg-(--color-surface) border-b border-(--color-border)/50">{label}</p>
|
||||
@@ -454,8 +454,11 @@
|
||||
<!-- Chapter modal (full-screen overlay) -->
|
||||
{#if showChapterModal && audioStore.chapters.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="absolute inset-0 z-70 flex flex-col" style="background: var(--color-surface);">
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0">
|
||||
<div
|
||||
class="fixed inset-0 z-[80] flex flex-col"
|
||||
style="background: var(--color-surface);"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-(--color-border) shrink-0" style="padding-top: max(0.75rem, env(safe-area-inset-top));">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { showChapterModal = false; }}
|
||||
@@ -481,7 +484,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="flex-1 overflow-y-auto overscroll-contain" style="padding-bottom: env(safe-area-inset-bottom);">
|
||||
{#each filteredChapters as ch (ch.number)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -260,6 +260,11 @@
|
||||
navigator.mediaSession.playbackState = audioStore.isPlaying ? 'playing' : 'paused';
|
||||
});
|
||||
|
||||
// ── Announce-chapter safety timeout ──────────────────────────────────────
|
||||
// Module-level so the onended handler can clear it if the clip completes
|
||||
// before the timeout fires.
|
||||
let announceTimeout = 0;
|
||||
|
||||
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
|
||||
let audioTimeSaveTimer = 0;
|
||||
function saveAudioTime() {
|
||||
@@ -366,6 +371,22 @@
|
||||
}}
|
||||
onended={() => {
|
||||
audioStore.isPlaying = false;
|
||||
|
||||
// ── If we just finished playing an announcement clip, navigate now ──
|
||||
if (audioStore.announceNavigatePending) {
|
||||
audioStore.announceNavigatePending = false;
|
||||
clearTimeout(announceTimeout);
|
||||
announceTimeout = 0;
|
||||
const slug = audioStore.announcePendingSlug;
|
||||
const chapter = audioStore.announcePendingChapter;
|
||||
audioStore.announcePendingSlug = '';
|
||||
audioStore.announcePendingChapter = 0;
|
||||
goto(`/books/${slug}/chapters/${chapter}`).catch(() => {
|
||||
audioStore.autoStartChapter = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any pending debounced save and reset the position to 0 for
|
||||
// the chapter that just finished. Without this, the 2s debounce fires
|
||||
// after navigation and saves currentTime≈duration, causing resume to
|
||||
@@ -390,45 +411,53 @@
|
||||
// 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 targetSlug = audioStore.slug;
|
||||
const targetChapter = audioStore.nextChapter;
|
||||
// Store the target chapter number so only the newly-mounted AudioPlayer
|
||||
// for that chapter reacts — not the outgoing chapter's component.
|
||||
audioStore.autoStartChapter = targetChapter;
|
||||
|
||||
// Announce the upcoming chapter via Web Speech API if enabled.
|
||||
const doNavigate = () => {
|
||||
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
|
||||
audioStore.autoStartChapter = null;
|
||||
});
|
||||
};
|
||||
|
||||
if (audioStore.announceChapter && typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
const nextInfo = audioStore.chapters.find((c) => c.number === targetChapter);
|
||||
// Announce via a real audio clip so the audio session stays alive on
|
||||
// iOS Safari / Chrome Android (speechSynthesis is silently muted after
|
||||
// onended because the audio session has been released).
|
||||
if (audioStore.announceChapter) {
|
||||
const nextInfo = audioStore.chapters.find((c) => c.number === targetChapter);
|
||||
const titlePart = nextInfo?.title ? ` — ${nextInfo.title}` : '';
|
||||
const text = `Chapter ${targetChapter}${titlePart}`;
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
const text = `Chapter ${targetChapter}${titlePart}`;
|
||||
|
||||
// Guard: ensure doNavigate can only fire once even if both
|
||||
// onend and the timeout fire, or onerror fires after onend.
|
||||
let navigated = false;
|
||||
const safeNavigate = () => {
|
||||
if (navigated) return;
|
||||
navigated = true;
|
||||
clearTimeout(announceTimeout);
|
||||
doNavigate();
|
||||
};
|
||||
// Always request MP3 — universally supported and the backend
|
||||
// auto-selects the right TTS engine from the voice ID.
|
||||
const qs = new URLSearchParams({ text, voice: audioStore.voice, format: 'mp3' });
|
||||
const announceUrl = `/api/announce?${qs}`;
|
||||
|
||||
// Hard fallback: if speechSynthesis silently drops the utterance
|
||||
// (common on Chrome Android due to gesture policy, or when the
|
||||
// browser is busy fetching the next chapter's audio), navigate
|
||||
// anyway after a generous 8-second window.
|
||||
const announceTimeout = setTimeout(safeNavigate, 8000);
|
||||
// Store pending navigation target so the next onended (from the
|
||||
// announcement clip) knows where to go.
|
||||
audioStore.announcePendingSlug = targetSlug;
|
||||
audioStore.announcePendingChapter = targetChapter;
|
||||
audioStore.announceNavigatePending = true;
|
||||
|
||||
utterance.onend = safeNavigate;
|
||||
utterance.onerror = safeNavigate;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
// Safety timeout: if the clip never loads/ends (network issue,
|
||||
// browser policy, unsupported codec), navigate anyway after 10s.
|
||||
clearTimeout(announceTimeout);
|
||||
announceTimeout = setTimeout(() => {
|
||||
if (audioStore.announceNavigatePending) {
|
||||
audioStore.announceNavigatePending = false;
|
||||
audioStore.announcePendingSlug = '';
|
||||
audioStore.announcePendingChapter = 0;
|
||||
doNavigate();
|
||||
}
|
||||
}, 10_000) as unknown as number;
|
||||
|
||||
// Point the persistent <audio> element at the announcement clip.
|
||||
// The $effect in the layout that watches audioStore.audioUrl will
|
||||
// pick this up, set audioEl.src, and call play().
|
||||
audioStore.audioUrl = announceUrl;
|
||||
} else {
|
||||
doNavigate();
|
||||
}
|
||||
|
||||
@@ -73,50 +73,76 @@
|
||||
];
|
||||
|
||||
// ── Hero carousel ────────────────────────────────────────────────────────
|
||||
const CAROUSEL_INTERVAL = 6000; // ms
|
||||
const heroBooks = $derived(data.continueInProgress);
|
||||
let heroIndex = $state(0);
|
||||
const heroBook = $derived(heroBooks[heroIndex] ?? null);
|
||||
// Shelf shows remaining books not in the hero
|
||||
const shelfBooks = $derived(
|
||||
heroBooks.length > 1 ? heroBooks.filter((_, i) => i !== heroIndex) : []
|
||||
);
|
||||
// Shelf always shows books at positions 1…n — stable regardless of heroIndex
|
||||
// so that navigating the carousel doesn't reshuffle the shelf below.
|
||||
const shelfBooks = $derived(heroBooks.length > 1 ? heroBooks.slice(1) : []);
|
||||
const streak = $derived(data.stats.streak ?? 0);
|
||||
|
||||
function heroPrev() {
|
||||
heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroNext() {
|
||||
heroIndex = (heroIndex + 1) % heroBooks.length;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
function heroDot(i: number) {
|
||||
heroIndex = i;
|
||||
resetAutoAdvance();
|
||||
}
|
||||
|
||||
// Auto-advance carousel every 6 s when there are multiple books.
|
||||
// We use a $state counter as a "restart token" so the $effect can be
|
||||
// re-triggered by manual navigation without reading heroIndex (which would
|
||||
// cause an infinite loop when the interval itself mutates heroIndex).
|
||||
// Auto-advance carousel every CAROUSEL_INTERVAL ms when there are multiple books.
|
||||
// autoAdvanceSeed is bumped on manual swipe/dot to restart the interval.
|
||||
let autoAdvanceSeed = $state(0);
|
||||
// progressStart tracks when the current interval began (for the progress bar).
|
||||
let progressStart = $state(browser ? performance.now() : 0);
|
||||
|
||||
$effect(() => {
|
||||
if (heroBooks.length <= 1) return;
|
||||
// Subscribe to heroBooks.length and autoAdvanceSeed only — not heroIndex.
|
||||
const len = heroBooks.length;
|
||||
void autoAdvanceSeed; // track the seed
|
||||
void autoAdvanceSeed; // restart when seed changes
|
||||
progressStart = browser ? performance.now() : 0;
|
||||
const id = setInterval(() => {
|
||||
heroIndex = (heroIndex + 1) % len;
|
||||
}, 6000);
|
||||
progressStart = browser ? performance.now() : 0;
|
||||
}, CAROUSEL_INTERVAL);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
function resetAutoAdvance() {
|
||||
// Bump the seed to restart the interval after manual navigation.
|
||||
autoAdvanceSeed++;
|
||||
}
|
||||
|
||||
// ── Swipe handling ───────────────────────────────────────────────────────
|
||||
let swipeStartX = 0;
|
||||
function onSwipeStart(e: TouchEvent) {
|
||||
swipeStartX = e.touches[0].clientX;
|
||||
}
|
||||
function onSwipeEnd(e: TouchEvent) {
|
||||
const dx = e.changedTouches[0].clientX - swipeStartX;
|
||||
if (Math.abs(dx) < 40) return; // ignore tiny movements
|
||||
if (dx < 0) {
|
||||
// swipe left → next
|
||||
heroIndex = (heroIndex + 1) % heroBooks.length;
|
||||
} else {
|
||||
// swipe right → prev
|
||||
heroIndex = (heroIndex - 1 + heroBooks.length) % heroBooks.length;
|
||||
}
|
||||
resetAutoAdvance();
|
||||
}
|
||||
|
||||
// ── Progress bar animation ───────────────────────────────────────────────
|
||||
// rAF loop drives a 0→1 progress value that resets on each advance.
|
||||
let rafProgress = $state(0);
|
||||
$effect(() => {
|
||||
if (!browser || heroBooks.length <= 1) return;
|
||||
void autoAdvanceSeed; // re-subscribe so effect re-runs on manual nav
|
||||
void heroIndex;
|
||||
let raf: number;
|
||||
function tick() {
|
||||
rafProgress = Math.min((performance.now() - progressStart) / CAROUSEL_INTERVAL, 1);
|
||||
raf = requestAnimationFrame(tick);
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
});
|
||||
|
||||
function playChapter(slug: string, chapter: number) {
|
||||
audioStore.autoStartChapter = chapter;
|
||||
goto(`/books/${slug}/chapters/${chapter}`);
|
||||
@@ -131,8 +157,13 @@
|
||||
{#if heroBook}
|
||||
<section class="mb-6">
|
||||
<div class="relative">
|
||||
<!-- Card -->
|
||||
<div class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all">
|
||||
<!-- Card — swipe to navigate -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group relative flex gap-0 rounded-xl overflow-hidden bg-(--color-surface-2) border border-(--color-border) hover:border-(--color-brand)/50 transition-all"
|
||||
ontouchstart={onSwipeStart}
|
||||
ontouchend={onSwipeEnd}
|
||||
>
|
||||
<!-- Cover -->
|
||||
<a href="/books/{heroBook.book.slug}/chapters/{heroBook.chapter}"
|
||||
class="w-32 sm:w-44 shrink-0 aspect-[2/3] overflow-hidden block">
|
||||
@@ -188,44 +219,32 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prev / Next arrow buttons (only when multiple books) -->
|
||||
{#if heroBooks.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
onclick={heroPrev}
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Previous book"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={heroNext}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 rounded-full bg-(--color-surface)/80 border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors flex items-center justify-center backdrop-blur-sm z-10"
|
||||
aria-label="Next book"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dot indicators -->
|
||||
<!-- Dot indicators with animated progress line under active dot -->
|
||||
{#if heroBooks.length > 1}
|
||||
<div class="flex items-center justify-center gap-1.5 mt-2.5">
|
||||
<div class="flex items-center justify-center gap-2 mt-2.5">
|
||||
{#each heroBooks as _, i}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => heroDot(i)}
|
||||
aria-label="Go to book {i + 1}"
|
||||
class="rounded-full transition-all duration-300 {i === heroIndex
|
||||
class="relative flex flex-col items-center gap-0.5 group/dot"
|
||||
>
|
||||
<!-- dot -->
|
||||
<span class="block rounded-full transition-all duration-300 {i === heroIndex
|
||||
? 'w-4 h-1.5 bg-(--color-brand)'
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) hover:bg-(--color-muted)'}"
|
||||
></button>
|
||||
: 'w-1.5 h-1.5 bg-(--color-border) group-hover/dot:bg-(--color-muted)'}"></span>
|
||||
<!-- progress line — only visible under the active dot -->
|
||||
{#if i === heroIndex}
|
||||
<span class="absolute -bottom-1.5 left-0 h-0.5 w-full bg-(--color-border) rounded-full overflow-hidden">
|
||||
<span
|
||||
class="block h-full bg-(--color-brand) rounded-full"
|
||||
style="width: {rafProgress * 100}%"
|
||||
></span>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
39
ui/src/routes/api/announce/+server.ts
Normal file
39
ui/src/routes/api/announce/+server.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/announce?text=...&voice=...&format=...
|
||||
*
|
||||
* Thin proxy to backend GET /api/tts-announce.
|
||||
* No paywall — this is a short announcement clip (a few words), not chapter audio.
|
||||
* No MinIO caching — the backend streams the clip directly.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const text = url.searchParams.get('text') ?? '';
|
||||
if (!text) error(400, 'text is required');
|
||||
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('text', text);
|
||||
|
||||
const voice = url.searchParams.get('voice');
|
||||
if (voice) qs.set('voice', voice);
|
||||
|
||||
const format = url.searchParams.get('format') ?? 'mp3';
|
||||
qs.set('format', format);
|
||||
|
||||
const backendRes = await backendFetch(`/api/tts-announce?${qs}`);
|
||||
|
||||
if (!backendRes.ok) {
|
||||
error(backendRes.status as Parameters<typeof error>[0], 'TTS announce failed');
|
||||
}
|
||||
|
||||
return new Response(backendRes.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': backendRes.headers.get('Content-Type') ?? 'audio/mpeg',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Accel-Buffering': 'no'
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -526,6 +526,7 @@
|
||||
chapters={data.chapters}
|
||||
voices={data.voices}
|
||||
playerStyle={layout.playerStyle}
|
||||
wordCount={wordCount}
|
||||
onProRequired={() => { audioProRequired = true; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user