Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f83a7c05f | ||
|
|
93e9d88066 |
@@ -101,6 +101,13 @@ class AudioStore {
|
||||
*/
|
||||
autoNext = $state(false);
|
||||
|
||||
/**
|
||||
* When true, announces the upcoming chapter number and title via the
|
||||
* Web Speech API before auto-next navigation fires.
|
||||
* e.g. "Chapter 12 — The Final Battle"
|
||||
*/
|
||||
announceChapter = $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.
|
||||
|
||||
@@ -618,8 +618,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Secondary controls: unified single row — Speed · Auto · Sleep -->
|
||||
<div class="flex items-center justify-center gap-2 shrink-0">
|
||||
<!-- Secondary controls: unified single row — Speed · Auto · Announce · Sleep -->
|
||||
<div class="flex items-center justify-center gap-2 shrink-0 flex-wrap">
|
||||
<!-- Speed — segmented pill -->
|
||||
<div class="flex items-center gap-0.5 bg-(--color-surface-2) rounded-full px-1.5 py-1 border border-(--color-border)">
|
||||
{#each SPEED_OPTIONS as s}
|
||||
@@ -661,6 +661,25 @@
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Announce chapter pill (only meaningful when auto-next is on) -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (audioStore.announceChapter = !audioStore.announceChapter)}
|
||||
class={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors',
|
||||
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>
|
||||
|
||||
<!-- Sleep timer pill -->
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface PBUserSettings {
|
||||
locale?: string;
|
||||
font_family?: string;
|
||||
font_size?: number;
|
||||
announce_chapter?: boolean;
|
||||
updated?: string;
|
||||
}
|
||||
|
||||
@@ -998,7 +999,7 @@ export async function getSettings(
|
||||
|
||||
export async function saveSettings(
|
||||
sessionId: string,
|
||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number },
|
||||
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
|
||||
userId?: string
|
||||
): Promise<void> {
|
||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
||||
@@ -1017,6 +1018,7 @@ export async function saveSettings(
|
||||
if (settings.locale !== undefined) payload.locale = settings.locale;
|
||||
if (settings.fontFamily !== undefined) payload.font_family = settings.fontFamily;
|
||||
if (settings.fontSize !== undefined) payload.font_size = settings.fontSize;
|
||||
if (settings.announceChapter !== undefined) payload.announce_chapter = settings.announceChapter;
|
||||
if (userId) payload.user_id = userId;
|
||||
|
||||
if (existing) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0 };
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
|
||||
try {
|
||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||
if (row) {
|
||||
@@ -28,7 +28,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
||||
theme: row.theme ?? 'amber',
|
||||
locale: row.locale ?? 'en',
|
||||
fontFamily: row.font_family ?? 'system',
|
||||
fontSize: row.font_size || 1.0
|
||||
fontSize: row.font_size || 1.0,
|
||||
announceChapter: row.announce_chapter ?? false
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
audioStore.autoNext = data.settings.autoNext;
|
||||
audioStore.voice = data.settings.voice;
|
||||
audioStore.speed = data.settings.speed;
|
||||
audioStore.announceChapter = data.settings.announceChapter ?? false;
|
||||
}
|
||||
// Always sync theme + font (profile page calls invalidateAll after saving)
|
||||
currentTheme = data.settings.theme ?? 'amber';
|
||||
@@ -128,6 +129,7 @@
|
||||
const theme = currentTheme;
|
||||
const fontFamily = currentFontFamily;
|
||||
const fontSize = currentFontSize;
|
||||
const announceChapter = audioStore.announceChapter;
|
||||
|
||||
// Skip saving until settings have been applied from the server AND
|
||||
// at least one user-driven change has occurred after that.
|
||||
@@ -138,7 +140,7 @@
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize })
|
||||
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
|
||||
}).catch(() => {});
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
@@ -377,9 +379,26 @@
|
||||
// Store the target chapter number so only the newly-mounted AudioPlayer
|
||||
// for that chapter reacts — not the outgoing chapter's component.
|
||||
audioStore.autoStartChapter = targetChapter;
|
||||
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
|
||||
audioStore.autoStartChapter = null;
|
||||
});
|
||||
|
||||
// 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);
|
||||
const titlePart = nextInfo?.title ? ` — ${nextInfo.title}` : '';
|
||||
const text = `Chapter ${targetChapter}${titlePart}`;
|
||||
window.speechSynthesis.cancel();
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
utterance.onend = doNavigate;
|
||||
utterance.onerror = doNavigate;
|
||||
window.speechSynthesis.speak(utterance);
|
||||
} else {
|
||||
doNavigate();
|
||||
}
|
||||
}
|
||||
}}
|
||||
preload="metadata"
|
||||
|
||||
@@ -329,6 +329,12 @@
|
||||
if (input) input.value = '';
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// ── Selected model info ──────────────────────────────────────────────────────
|
||||
let selectedModelInfo = $derived(models.find((m) => m.id === selectedModel) ?? null);
|
||||
let refWarning = $derived(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize).
|
||||
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
|
||||
* Returns defaults if no settings record exists yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
@@ -18,7 +18,8 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
theme: settings?.theme ?? 'amber',
|
||||
locale: settings?.locale ?? 'en',
|
||||
fontFamily: settings?.font_family ?? 'system',
|
||||
fontSize: settings?.font_size || 1.0
|
||||
fontSize: settings?.font_size || 1.0,
|
||||
announceChapter: settings?.announce_chapter ?? false
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
@@ -28,7 +29,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
|
||||
/**
|
||||
* PUT /api/settings
|
||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number }
|
||||
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
|
||||
* Saves user preferences.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
@@ -67,6 +68,11 @@ export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
error(400, `Invalid fontSize — must be one of: ${validFontSizes.join(', ')}`);
|
||||
}
|
||||
|
||||
// announceChapter is optional boolean
|
||||
if (body.announceChapter !== undefined && typeof body.announceChapter !== 'boolean') {
|
||||
error(400, 'Invalid announceChapter — must be boolean');
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user