feat: stream/generate audio mode toggle
Add a user-selectable playback mode stored in user_settings: - 'stream' (default): /api/audio-stream starts playing within seconds, saves to MinIO concurrently — low latency - 'generate': queue runner task, poll until full audio is ready in MinIO, then play via presigned URL — legacy behaviour UI toggles in two places: - AudioPlayer idle pill: compact '· Stream / · Generate' inline next to voice name and estimated duration - ListeningMode controls row: pill alongside Auto, Announce, Sleep; disabled and grayed out for CF AI voices (batch-only, no streaming) startPlayback() now branches on audioStore.audioMode for non-CF AI voices; generate mode uses the same runner task + progress bar flow as CF AI but without the preview clip. PocketBase: audio_mode text field added to user_settings on pb.libnovel.cc (live) and in pb-init-v3.sh (create block + add_field migration line).
This commit is contained in:
@@ -36,6 +36,14 @@ import type { Voice } from '$lib/types';
|
|||||||
|
|
||||||
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
export type AudioStatus = 'idle' | 'loading' | 'generating' | 'ready' | 'error';
|
||||||
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
export type NextStatus = 'none' | 'prefetching' | 'prefetched' | 'failed';
|
||||||
|
/**
|
||||||
|
* 'stream' – Use /api/audio-stream: audio starts playing within seconds,
|
||||||
|
* stream is saved to MinIO concurrently. No runner task needed.
|
||||||
|
* 'generate' – Legacy mode: queue a runner task, poll until done, then play
|
||||||
|
* from the presigned MinIO URL. Needed for CF AI voices which
|
||||||
|
* do not support native streaming.
|
||||||
|
*/
|
||||||
|
export type AudioMode = 'stream' | 'generate';
|
||||||
|
|
||||||
class AudioStore {
|
class AudioStore {
|
||||||
// ── What is loaded ──────────────────────────────────────────────────────
|
// ── What is loaded ──────────────────────────────────────────────────────
|
||||||
@@ -46,6 +54,13 @@ class AudioStore {
|
|||||||
voice = $state('af_bella');
|
voice = $state('af_bella');
|
||||||
speed = $state(1.0);
|
speed = $state(1.0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playback mode:
|
||||||
|
* 'stream' – pipe from /api/audio-stream (low latency, saves concurrently)
|
||||||
|
* 'generate' – queue runner task, poll, then play presigned URL (CF AI / legacy)
|
||||||
|
*/
|
||||||
|
audioMode = $state<AudioMode>('stream');
|
||||||
|
|
||||||
/** Cover image URL for the currently loaded book. */
|
/** Cover image URL for the currently loaded book. */
|
||||||
cover = $state('');
|
cover = $state('');
|
||||||
|
|
||||||
|
|||||||
@@ -613,41 +613,95 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: audio not yet in MinIO.
|
// Slow path: audio not yet in MinIO.
|
||||||
//
|
//
|
||||||
// For Kokoro / PocketTTS: always use the streaming endpoint so audio
|
// For Kokoro / PocketTTS in 'stream' mode: use the streaming endpoint so
|
||||||
// starts playing within seconds. The stream handler checks MinIO first
|
// audio starts playing within seconds. The stream handler checks MinIO
|
||||||
// (fast redirect if already cached) and otherwise generates + uploads
|
// first (fast redirect if already cached) and otherwise generates +
|
||||||
// concurrently. Even if the async runner is already working on this
|
// uploads concurrently.
|
||||||
// chapter, the stream will redirect to MinIO the moment the runner
|
//
|
||||||
// finishes — no harmful double-generation occurs because the backend
|
// In 'generate' mode (user preference): queue a runner task and poll,
|
||||||
// deduplications via AudioExists on the next request.
|
// same as CF AI — audio plays only after the full file is ready in MinIO.
|
||||||
if (!voice.startsWith('cfai:')) {
|
if (!voice.startsWith('cfai:') && audioStore.audioMode === 'stream') {
|
||||||
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
// PocketTTS outputs raw WAV — skip the ffmpeg transcode entirely.
|
||||||
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
// WAV (PCM) is natively supported on all platforms including iOS Safari.
|
||||||
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
// Kokoro and CF AI output MP3 natively, so keep mp3 for those.
|
||||||
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
const isPocketTTS = voices.some((v) => v.id === voice && v.engine === 'pocket-tts');
|
||||||
const format = isPocketTTS ? 'wav' : 'mp3';
|
const format = isPocketTTS ? 'wav' : 'mp3';
|
||||||
const qs = new URLSearchParams({ voice, format });
|
const qs = new URLSearchParams({ voice, format });
|
||||||
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
const streamUrl = `/api/audio-stream/${slug}/${chapter}?${qs}`;
|
||||||
// HEAD probe: check paywall without triggering generation.
|
// HEAD probe: check paywall without triggering generation.
|
||||||
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
const headRes = await fetch(streamUrl, { method: 'HEAD' }).catch(() => null);
|
||||||
if (headRes?.status === 402) {
|
if (headRes?.status === 402) {
|
||||||
|
audioStore.status = 'idle';
|
||||||
|
onProRequired?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
audioStore.audioUrl = streamUrl;
|
||||||
|
audioStore.status = 'ready';
|
||||||
|
maybeStartPrefetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-CF AI voices in 'generate' mode: queue runner task, show progress,
|
||||||
|
// wait for full audio in MinIO before playing (same as CF AI but no preview).
|
||||||
|
if (!voice.startsWith('cfai:')) {
|
||||||
|
audioStore.status = 'generating';
|
||||||
|
audioStore.isPreview = false;
|
||||||
|
startProgress();
|
||||||
|
|
||||||
|
if (!presignResult.enqueued) {
|
||||||
|
const res = await fetch(`/api/audio/${slug}/${chapter}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ voice })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 402) {
|
||||||
audioStore.status = 'idle';
|
audioStore.status = 'idle';
|
||||||
|
stopProgress();
|
||||||
onProRequired?.();
|
onProRequired?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
audioStore.audioUrl = streamUrl;
|
|
||||||
audioStore.status = 'ready';
|
if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`);
|
||||||
maybeStartPrefetch();
|
|
||||||
return;
|
if (res.status === 200) {
|
||||||
|
await res.body?.cancel();
|
||||||
|
await finishProgress();
|
||||||
|
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||||
|
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||||
|
audioStore.audioUrl = doneUrl.url;
|
||||||
|
audioStore.status = 'ready';
|
||||||
|
restoreSavedAudioTime();
|
||||||
|
maybeStartPrefetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 202 — runner task enqueued, fall through to poll.
|
||||||
}
|
}
|
||||||
|
|
||||||
// CF AI voices: use preview/swap strategy.
|
const final = await pollAudioStatus(slug, chapter, voice);
|
||||||
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
if (final.status === 'failed') {
|
||||||
// so playback starts immediately — no more waiting behind a spinner.
|
throw new Error(
|
||||||
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
`Generation failed: ${(final as { error?: string }).error ?? 'unknown error'}`
|
||||||
// swap the <audio> src to the full URL preserving currentTime.
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await finishProgress();
|
||||||
|
const doneUrl = await tryPresign(slug, chapter, voice);
|
||||||
|
if (!doneUrl.ready) throw new Error('Audio generated but presign returned 404');
|
||||||
|
audioStore.audioUrl = doneUrl.url;
|
||||||
|
audioStore.status = 'ready';
|
||||||
|
restoreSavedAudioTime();
|
||||||
|
maybeStartPrefetch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CF AI voices: use preview/swap strategy.
|
||||||
|
// 1. Fetch a short ~1-2 min preview clip from the first text chunk
|
||||||
|
// so playback starts immediately — no more waiting behind a spinner.
|
||||||
|
// 2. Meanwhile keep polling the full audio job; when it finishes,
|
||||||
|
// swap the <audio> src to the full URL preserving currentTime.
|
||||||
audioStore.status = 'generating';
|
audioStore.status = 'generating';
|
||||||
audioStore.isPreview = false;
|
audioStore.isPreview = false;
|
||||||
startProgress();
|
startProgress();
|
||||||
@@ -1019,6 +1073,33 @@
|
|||||||
{#if voices.length > 0}<span class="text-(--color-border) text-xs leading-none">·</span>{/if}
|
{#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>
|
<span class="text-xs text-(--color-muted) leading-none tabular-nums">~{estimatedMinutes} min</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
<!-- Stream / Generate mode toggle -->
|
||||||
|
{#if !audioStore.voice.startsWith('cfai:')}
|
||||||
|
<span class="text-(--color-border) text-xs leading-none">·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => { audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream'; }}
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-0.5 text-xs leading-none transition-colors',
|
||||||
|
audioStore.audioMode === 'stream'
|
||||||
|
? 'text-(--color-brand)'
|
||||||
|
: 'text-(--color-muted) hover:text-(--color-text)'
|
||||||
|
)}
|
||||||
|
title={audioStore.audioMode === 'stream' ? 'Stream mode — click to switch to generate' : 'Generate mode — click to switch to stream'}
|
||||||
|
>
|
||||||
|
{#if audioStore.audioMode === 'stream'}
|
||||||
|
<svg class="w-3 h-3 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M8 5v14l11-7z"/>
|
||||||
|
</svg>
|
||||||
|
Stream
|
||||||
|
{:else}
|
||||||
|
<svg class="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||||
|
</svg>
|
||||||
|
Generate
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -664,24 +664,57 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
|
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||||
audioStore.announceChapter
|
audioStore.announceChapter
|
||||||
|
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||||
|
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||||
|
)}
|
||||||
|
aria-pressed={audioStore.announceChapter}
|
||||||
|
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
|
||||||
|
>
|
||||||
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
||||||
|
</svg>
|
||||||
|
Announce
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Stream / Generate mode toggle -->
|
||||||
|
<!-- CF AI voices are batch-only and always use generate mode regardless of this setting -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => {
|
||||||
|
if (!audioStore.voice.startsWith('cfai:')) {
|
||||||
|
audioStore.audioMode = audioStore.audioMode === 'stream' ? 'generate' : 'stream';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={audioStore.voice.startsWith('cfai:')}
|
||||||
|
class={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||||
|
audioStore.voice.startsWith('cfai:')
|
||||||
|
? 'border-(--color-border) bg-(--color-surface-2) text-(--color-border) cursor-not-allowed opacity-50'
|
||||||
|
: audioStore.audioMode === 'stream'
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
? 'border-(--color-brand) bg-(--color-brand)/15 text-(--color-brand)'
|
||||||
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
: 'border-(--color-border) bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
||||||
)}
|
)}
|
||||||
aria-pressed={audioStore.announceChapter}
|
aria-pressed={audioStore.audioMode === 'stream'}
|
||||||
title={audioStore.announceChapter ? 'Chapter announcing on' : 'Chapter announcing off'}
|
title={audioStore.voice.startsWith('cfai:') ? 'CF AI voices always use generate mode' : audioStore.audioMode === 'stream' ? 'Stream mode — audio starts instantly' : 'Generate mode — wait for full audio before playing'}
|
||||||
>
|
>
|
||||||
|
{#if audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:')}
|
||||||
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
|
<path d="M8 5v14l11-7z"/>
|
||||||
</svg>
|
</svg>
|
||||||
Announce
|
{:else}
|
||||||
</button>
|
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
{audioStore.audioMode === 'stream' && !audioStore.voice.startsWith('cfai:') ? 'Stream' : 'Generate'}
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Sleep timer pill -->
|
<!-- Sleep timer pill -->
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface PBUserSettings {
|
|||||||
font_family?: string;
|
font_family?: string;
|
||||||
font_size?: number;
|
font_size?: number;
|
||||||
announce_chapter?: boolean;
|
announce_chapter?: boolean;
|
||||||
|
audio_mode?: string;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,7 +1014,7 @@ export async function getSettings(
|
|||||||
|
|
||||||
export async function saveSettings(
|
export async function saveSettings(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
|
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean; audioMode?: string },
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
const existing = await listOne<PBUserSettings & { id: string }>(
|
||||||
@@ -1033,6 +1034,7 @@ export async function saveSettings(
|
|||||||
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
|
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
|
||||||
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
|
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
|
||||||
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
|
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
|
||||||
|
if (settings.audioMode !== undefined) payload.audio_mode = settings.audioMode;
|
||||||
if (userId) payload.user_id = userId;
|
if (userId) payload.user_id = userId;
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
|||||||
redirect(302, `/login`);
|
redirect(302, `/login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
|
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false, audioMode: 'stream' };
|
||||||
try {
|
try {
|
||||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -29,7 +29,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
|||||||
locale: row.locale ?? 'en',
|
locale: row.locale ?? 'en',
|
||||||
fontFamily: row.font_family ?? 'system',
|
fontFamily: row.font_family ?? 'system',
|
||||||
fontSize: row.font_size || 1.0,
|
fontSize: row.font_size || 1.0,
|
||||||
announceChapter: row.announce_chapter ?? false
|
announceChapter: row.announce_chapter ?? false,
|
||||||
|
audioMode: row.audio_mode ?? 'stream'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -157,6 +157,7 @@
|
|||||||
audioStore.voice = data.settings.voice;
|
audioStore.voice = data.settings.voice;
|
||||||
audioStore.speed = data.settings.speed;
|
audioStore.speed = data.settings.speed;
|
||||||
audioStore.announceChapter = data.settings.announceChapter ?? false;
|
audioStore.announceChapter = data.settings.announceChapter ?? false;
|
||||||
|
audioStore.audioMode = (data.settings.audioMode === 'generate' ? 'generate' : 'stream');
|
||||||
}
|
}
|
||||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||||
currentTheme = data.settings.theme ?? 'amber';
|
currentTheme = data.settings.theme ?? 'amber';
|
||||||
@@ -179,6 +180,7 @@
|
|||||||
const fontFamily = currentFontFamily;
|
const fontFamily = currentFontFamily;
|
||||||
const fontSize = currentFontSize;
|
const fontSize = currentFontSize;
|
||||||
const announceChapter = audioStore.announceChapter;
|
const announceChapter = audioStore.announceChapter;
|
||||||
|
const audioMode = audioStore.audioMode;
|
||||||
|
|
||||||
// Skip saving until settings have been applied from the server AND
|
// Skip saving until settings have been applied from the server AND
|
||||||
// at least one user-driven change has occurred after that.
|
// at least one user-driven change has occurred after that.
|
||||||
@@ -189,7 +191,7 @@
|
|||||||
fetch('/api/settings', {
|
fetch('/api/settings', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
|
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter, audioMode })
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, 800) as unknown as number;
|
}, 800) as unknown as number;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/settings
|
* GET /api/settings
|
||||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
|
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter, audioMode).
|
||||||
* Returns defaults if no settings record exists yet.
|
* Returns defaults if no settings record exists yet.
|
||||||
*/
|
*/
|
||||||
export const GET: RequestHandler = async ({ locals }) => {
|
export const GET: RequestHandler = async ({ locals }) => {
|
||||||
@@ -19,7 +19,8 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
locale: settings?.locale ?? 'en',
|
locale: settings?.locale ?? 'en',
|
||||||
fontFamily: settings?.font_family ?? 'system',
|
fontFamily: settings?.font_family ?? 'system',
|
||||||
fontSize: settings?.font_size || 1.0,
|
fontSize: settings?.font_size || 1.0,
|
||||||
announceChapter: settings?.announce_chapter ?? false
|
announceChapter: settings?.announce_chapter ?? false,
|
||||||
|
audioMode: settings?.audio_mode ?? 'stream'
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('settings', 'GET failed', { err: String(e) });
|
log.error('settings', 'GET failed', { err: String(e) });
|
||||||
@@ -29,7 +30,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/settings
|
* PUT /api/settings
|
||||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
|
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean, audioMode?: string }
|
||||||
* Saves user preferences.
|
* Saves user preferences.
|
||||||
*/
|
*/
|
||||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||||
@@ -73,6 +74,12 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
|||||||
error(400, 'Invalid announceChapter — must be boolean');
|
error(400, 'Invalid announceChapter — must be boolean');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// audioMode is optional — if provided it must be a known value
|
||||||
|
const validAudioModes = ['stream', 'generate'];
|
||||||
|
if (body.audioMode !== undefined && !validAudioModes.includes(body.audioMode)) {
|
||||||
|
error(400, `Invalid audioMode — must be one of: ${validAudioModes.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user