feat: add chapter announcing setting to audio player
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 1m51s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m24s
Release / Docker / runner (push) Successful in 2m49s
Release / Upload source maps (push) Successful in 1m25s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 30s
All checks were successful
Release / Test backend (push) Successful in 43s
Release / Check ui (push) Successful in 1m51s
Release / Docker / caddy (push) Successful in 41s
Release / Docker / backend (push) Successful in 2m24s
Release / Docker / runner (push) Successful in 2m49s
Release / Upload source maps (push) Successful in 1m25s
Release / Docker / ui (push) Successful in 2m12s
Release / Gitea Release (push) Successful in 30s
When enabled, the Web Speech API speaks the upcoming chapter number and title (e.g. 'Chapter 12 — The Final Battle') between auto-next chapters, giving an audible cue before the next narration begins. - AudioStore.announceChapter ( boolean, default false) - PBUserSettings.announce_chapter persisted to PocketBase - GET/PUT /api/settings includes announceChapter field - +layout.server.ts loads + defaults the field - +layout.svelte applies on load, saves in debounced PUT, and fires SpeechSynthesisUtterance in onended before navigating (falls back to immediate navigation if speechSynthesis is unavailable) - ListeningMode: 'Announce' pill added to the Speed · Auto · Sleep row
This commit is contained in:
@@ -101,6 +101,13 @@ class AudioStore {
|
|||||||
*/
|
*/
|
||||||
autoNext = $state(false);
|
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
|
* The next chapter number for the currently playing chapter, or null if
|
||||||
* there is no next chapter. Written by the chapter page's AudioPlayer.
|
* there is no next chapter. Written by the chapter page's AudioPlayer.
|
||||||
|
|||||||
@@ -618,8 +618,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Secondary controls: unified single row — Speed · Auto · Sleep -->
|
<!-- Secondary controls: unified single row — Speed · Auto · Announce · Sleep -->
|
||||||
<div class="flex items-center justify-center gap-2 shrink-0">
|
<div class="flex items-center justify-center gap-2 shrink-0 flex-wrap">
|
||||||
<!-- Speed — segmented pill -->
|
<!-- 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)">
|
<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}
|
{#each SPEED_OPTIONS as s}
|
||||||
@@ -661,6 +661,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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 -->
|
<!-- Sleep timer pill -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export interface PBUserSettings {
|
|||||||
locale?: string;
|
locale?: string;
|
||||||
font_family?: string;
|
font_family?: string;
|
||||||
font_size?: number;
|
font_size?: number;
|
||||||
|
announce_chapter?: boolean;
|
||||||
updated?: string;
|
updated?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -998,7 +999,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 },
|
settings: { autoNext: boolean; voice: string; speed: number; theme?: string; locale?: string; fontFamily?: string; fontSize?: number; announceChapter?: boolean },
|
||||||
userId?: string
|
userId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const existing = await listOne<PBUserSettings & { id: string }>(
|
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.locale !== undefined) payload.locale = settings.locale;
|
||||||
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 (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 };
|
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0, theme: 'amber', locale: 'en', fontFamily: 'system', fontSize: 1.0, announceChapter: false };
|
||||||
try {
|
try {
|
||||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -28,7 +28,8 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => {
|
|||||||
theme: row.theme ?? 'amber',
|
theme: row.theme ?? 'amber',
|
||||||
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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
audioStore.autoNext = data.settings.autoNext;
|
audioStore.autoNext = data.settings.autoNext;
|
||||||
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;
|
||||||
}
|
}
|
||||||
// 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';
|
||||||
@@ -128,6 +129,7 @@
|
|||||||
const theme = currentTheme;
|
const theme = currentTheme;
|
||||||
const fontFamily = currentFontFamily;
|
const fontFamily = currentFontFamily;
|
||||||
const fontSize = currentFontSize;
|
const fontSize = currentFontSize;
|
||||||
|
const announceChapter = audioStore.announceChapter;
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -138,7 +140,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 })
|
body: JSON.stringify({ autoNext, voice, speed, theme, fontFamily, fontSize, announceChapter })
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}, 800) as unknown as number;
|
}, 800) as unknown as number;
|
||||||
});
|
});
|
||||||
@@ -377,9 +379,26 @@
|
|||||||
// Store the target chapter number so only the newly-mounted AudioPlayer
|
// Store the target chapter number so only the newly-mounted AudioPlayer
|
||||||
// for that chapter reacts — not the outgoing chapter's component.
|
// for that chapter reacts — not the outgoing chapter's component.
|
||||||
audioStore.autoStartChapter = targetChapter;
|
audioStore.autoStartChapter = targetChapter;
|
||||||
|
|
||||||
|
// Announce the upcoming chapter via Web Speech API if enabled.
|
||||||
|
const doNavigate = () => {
|
||||||
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
|
goto(`/books/${targetSlug}/chapters/${targetChapter}`).catch(() => {
|
||||||
audioStore.autoStartChapter = null;
|
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"
|
preload="metadata"
|
||||||
|
|||||||
@@ -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).
|
* Returns the current user's settings (auto_next, voice, speed, theme, locale, fontFamily, fontSize, announceChapter).
|
||||||
* 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 }) => {
|
||||||
@@ -18,7 +18,8 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
theme: settings?.theme ?? 'amber',
|
theme: settings?.theme ?? 'amber',
|
||||||
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
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('settings', 'GET failed', { err: String(e) });
|
log.error('settings', 'GET failed', { err: String(e) });
|
||||||
@@ -28,7 +29,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 }
|
* Body: { autoNext: boolean, voice: string, speed: number, theme?: string, locale?: string, fontFamily?: string, fontSize?: number, announceChapter?: boolean }
|
||||||
* Saves user preferences.
|
* Saves user preferences.
|
||||||
*/
|
*/
|
||||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
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(', ')}`);
|
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 {
|
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