feat(ui): persist user settings and audio position across sessions
- Replace double inline player controls with compact 'now playing' indicator when mini-bar is active - Add user_settings PocketBase collection (auto_next, voice, speed) and audio_time field on progress - Add getSettings/saveSettings/setAudioTime/getAudioTime server functions - Add GET/PUT /api/settings and GET/PATCH /api/progress/audio-time API routes - Load settings server-side in layout and apply to audioStore on mount - Debounced 800ms effect persists settings changes to DB - Save audio currentTime on pause/end; restore position when replaying a chapter
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
import { getSettings } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
const PUBLIC_ROUTES = new Set(['/login']);
|
||||
@@ -9,7 +11,22 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
redirect(302, `/login`);
|
||||
}
|
||||
|
||||
let settings = { autoNext: false, voice: 'af_bella', speed: 1.0 };
|
||||
try {
|
||||
const row = await getSettings(locals.sessionId, locals.user?.id);
|
||||
if (row) {
|
||||
settings = {
|
||||
autoNext: row.auto_next ?? false,
|
||||
voice: row.voice ?? 'af_bella',
|
||||
speed: row.speed ?? 1.0
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
log.warn('layout', 'failed to load settings', { err: String(e) });
|
||||
}
|
||||
|
||||
return {
|
||||
user: locals.user
|
||||
user: locals.user,
|
||||
settings
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,38 @@
|
||||
// AudioPlayer components in chapter pages control it via audioStore.
|
||||
let audioEl = $state<HTMLAudioElement | null>(null);
|
||||
|
||||
// Apply persisted settings once on mount (server-loaded data).
|
||||
let settingsApplied = false;
|
||||
$effect(() => {
|
||||
if (!settingsApplied && data.settings) {
|
||||
settingsApplied = true;
|
||||
audioStore.autoNext = data.settings.autoNext;
|
||||
audioStore.voice = data.settings.voice;
|
||||
audioStore.speed = data.settings.speed;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Persist settings changes (debounced 800ms) ──────────────────────────
|
||||
let settingsSaveTimer = 0;
|
||||
$effect(() => {
|
||||
// Subscribe to the three settings fields
|
||||
const autoNext = audioStore.autoNext;
|
||||
const voice = audioStore.voice;
|
||||
const speed = audioStore.speed;
|
||||
|
||||
// Skip saving until settings have been applied from the server
|
||||
if (!settingsApplied) return;
|
||||
|
||||
clearTimeout(settingsSaveTimer);
|
||||
settingsSaveTimer = setTimeout(() => {
|
||||
fetch('/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ autoNext, voice, speed })
|
||||
}).catch(() => {});
|
||||
}, 800) as unknown as number;
|
||||
});
|
||||
|
||||
// Keep the audio element's playback rate in sync with the store speed.
|
||||
$effect(() => {
|
||||
if (audioEl) audioEl.playbackRate = audioStore.speed;
|
||||
@@ -55,6 +87,23 @@
|
||||
audioStore.seekRequest = null;
|
||||
});
|
||||
|
||||
// ── Save audio time on pause/end (debounced 2s) ─────────────────────────
|
||||
let audioTimeSaveTimer = 0;
|
||||
function saveAudioTime() {
|
||||
if (!audioStore.slug || !audioStore.chapter) return;
|
||||
const slug = audioStore.slug;
|
||||
const chapter = audioStore.chapter;
|
||||
const currentTime = audioStore.currentTime;
|
||||
clearTimeout(audioTimeSaveTimer);
|
||||
audioTimeSaveTimer = setTimeout(() => {
|
||||
fetch('/api/progress/audio-time', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, chapter, audioTime: currentTime })
|
||||
}).catch(() => {});
|
||||
}, 2000) as unknown as number;
|
||||
}
|
||||
|
||||
function formatTime(s: number): string {
|
||||
if (!isFinite(s) || s < 0) return '0:00';
|
||||
const m = Math.floor(s / 60);
|
||||
@@ -122,9 +171,13 @@
|
||||
bind:currentTime={audioStore.currentTime}
|
||||
bind:duration={audioStore.duration}
|
||||
onplay={() => (audioStore.isPlaying = true)}
|
||||
onpause={() => (audioStore.isPlaying = false)}
|
||||
onpause={() => {
|
||||
audioStore.isPlaying = false;
|
||||
saveAudioTime();
|
||||
}}
|
||||
onended={() => {
|
||||
audioStore.isPlaying = false;
|
||||
saveAudioTime();
|
||||
if (audioStore.autoNext && audioStore.nextChapter !== null && audioStore.slug) {
|
||||
audioStore.autoStartPending = true;
|
||||
goto(`/books/${audioStore.slug}/chapters/${audioStore.nextChapter}`);
|
||||
|
||||
56
ui/src/routes/api/progress/audio-time/+server.ts
Normal file
56
ui/src/routes/api/progress/audio-time/+server.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { setAudioTime, getAudioTime } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/progress/audio-time?slug=&chapter=
|
||||
* Returns the last saved audio position for a chapter, or null.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url, locals }) => {
|
||||
const slug = url.searchParams.get('slug');
|
||||
const chapterParam = url.searchParams.get('chapter');
|
||||
|
||||
if (!slug || !chapterParam) {
|
||||
error(400, 'Missing slug or chapter query params');
|
||||
}
|
||||
|
||||
const chapter = parseInt(chapterParam, 10);
|
||||
if (isNaN(chapter)) {
|
||||
error(400, 'chapter must be a number');
|
||||
}
|
||||
|
||||
try {
|
||||
const audioTime = await getAudioTime(locals.sessionId, slug, chapter, locals.user?.id);
|
||||
return json({ audioTime });
|
||||
} catch (e) {
|
||||
log.error('audio-time', 'GET failed', { slug, chapter, err: String(e) });
|
||||
error(500, 'Failed to load audio time');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PATCH /api/progress/audio-time
|
||||
* Body: { slug: string, chapter: number, audioTime: number }
|
||||
* Saves the current audio playback position.
|
||||
*/
|
||||
export const PATCH: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body ||
|
||||
typeof body.slug !== 'string' ||
|
||||
typeof body.chapter !== 'number' ||
|
||||
typeof body.audioTime !== 'number'
|
||||
) {
|
||||
error(400, 'Invalid body — expected { slug, chapter, audioTime }');
|
||||
}
|
||||
|
||||
try {
|
||||
await setAudioTime(locals.sessionId, body.slug, body.chapter, body.audioTime, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('audio-time', 'PATCH failed', { slug: body.slug, chapter: body.chapter, err: String(e) });
|
||||
error(500, 'Failed to save audio time');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
49
ui/src/routes/api/settings/+server.ts
Normal file
49
ui/src/routes/api/settings/+server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getSettings, saveSettings } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
/**
|
||||
* GET /api/settings
|
||||
* Returns the current user's settings (auto_next, voice, speed).
|
||||
* Returns defaults if no settings record exists yet.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
try {
|
||||
const settings = await getSettings(locals.sessionId, locals.user?.id);
|
||||
return json({
|
||||
autoNext: settings?.auto_next ?? false,
|
||||
voice: settings?.voice ?? 'af_bella',
|
||||
speed: settings?.speed ?? 1.0
|
||||
});
|
||||
} catch (e) {
|
||||
log.error('settings', 'GET failed', { err: String(e) });
|
||||
error(500, 'Failed to load settings');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PUT /api/settings
|
||||
* Body: { autoNext: boolean, voice: string, speed: number }
|
||||
* Saves user preferences.
|
||||
*/
|
||||
export const PUT: RequestHandler = async ({ request, locals }) => {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body ||
|
||||
typeof body.autoNext !== 'boolean' ||
|
||||
typeof body.voice !== 'string' ||
|
||||
typeof body.speed !== 'number'
|
||||
) {
|
||||
error(400, 'Invalid body — expected { autoNext, voice, speed }');
|
||||
}
|
||||
|
||||
try {
|
||||
await saveSettings(locals.sessionId, body, locals.user?.id);
|
||||
} catch (e) {
|
||||
log.error('settings', 'PUT failed', { err: String(e) });
|
||||
error(500, 'Failed to save settings');
|
||||
}
|
||||
return json({ ok: true });
|
||||
};
|
||||
Reference in New Issue
Block a user