diff --git a/ui/src/lib/audio.svelte.ts b/ui/src/lib/audio.svelte.ts
index bd28b61..9d5cb00 100644
--- a/ui/src/lib/audio.svelte.ts
+++ b/ui/src/lib/audio.svelte.ts
@@ -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.
diff --git a/ui/src/lib/components/ListeningMode.svelte b/ui/src/lib/components/ListeningMode.svelte
index d08ccd1..ff49b16 100644
--- a/ui/src/lib/components/ListeningMode.svelte
+++ b/ui/src/lib/components/ListeningMode.svelte
@@ -618,8 +618,8 @@
{/if}
-
-
+
+
{#each SPEED_OPTIONS as s}
@@ -661,6 +661,25 @@
{/if}
+
+
(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'}
+ >
+
+
+
+ Announce
+
+
{
const existing = await listOne(
@@ -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) {
diff --git a/ui/src/routes/+layout.server.ts b/ui/src/routes/+layout.server.ts
index 62fb80e..767db91 100644
--- a/ui/src/routes/+layout.server.ts
+++ b/ui/src/routes/+layout.server.ts
@@ -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) {
diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte
index 8cbde99..81d9583 100644
--- a/ui/src/routes/+layout.svelte
+++ b/ui/src/routes/+layout.svelte
@@ -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"
diff --git a/ui/src/routes/api/settings/+server.ts b/ui/src/routes/api/settings/+server.ts
index d449a16..d2fe4f8 100644
--- a/ui/src/routes/api/settings/+server.ts
+++ b/ui/src/routes/api/settings/+server.ts
@@ -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) {