perf: remove voice picker from profile, parallelize server load
Remove the TTS voice section from the profile page — it fetched /api/voices on every mount, blocking paint for the full round-trip. Voice selection lives on the chapter page where voices are already loaded. Rewrite the server load to run avatar, sessions+stats, and reading history all concurrently via Promise.allSettled instead of sequentially, cutting SSR latency by ~2-3x on the profile route.
This commit is contained in:
@@ -15,49 +15,58 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
redirect(302, '/login');
|
redirect(302, '/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
let sessions: Awaited<ReturnType<typeof listUserSessions>> = [];
|
// Helper: fetch reading history (progress → books, sequential by necessity)
|
||||||
let email: string | null = null;
|
async function fetchHistory() {
|
||||||
let polarCustomerId: string | null = null;
|
const progress = await allProgress(locals.sessionId, locals.user!.id);
|
||||||
let stats: Awaited<ReturnType<typeof getUserStats>> | null = null;
|
|
||||||
|
|
||||||
// Fetch avatar — MinIO first, fall back to OAuth provider picture
|
|
||||||
let avatarUrl: string | null = null;
|
|
||||||
try {
|
|
||||||
const record = await getUserByUsername(locals.user.username);
|
|
||||||
avatarUrl = await resolveAvatarUrl(locals.user.id, record?.avatar_url);
|
|
||||||
email = record?.email ?? null;
|
|
||||||
polarCustomerId = record?.polar_customer_id ?? null;
|
|
||||||
} catch (e) {
|
|
||||||
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(e) });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
[sessions, stats] = await Promise.all([
|
|
||||||
listUserSessions(locals.user.id),
|
|
||||||
getUserStats(locals.sessionId, locals.user.id)
|
|
||||||
]);
|
|
||||||
} catch (e) {
|
|
||||||
log.warn('profile', 'load failed (non-fatal)', { err: String(e) });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reading history — last 50 progress entries with book metadata
|
|
||||||
let history: { slug: string; chapter: number; updated: string; title: string; cover: string | null }[] = [];
|
|
||||||
try {
|
|
||||||
const progress = await allProgress(locals.sessionId, locals.user.id);
|
|
||||||
const recent = progress.slice(0, 50);
|
const recent = progress.slice(0, 50);
|
||||||
const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug)));
|
const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug)));
|
||||||
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
const bookMap = new Map(books.map((b) => [b.slug, b]));
|
||||||
history = recent.map((p) => ({
|
return recent.map((p) => ({
|
||||||
slug: p.slug,
|
slug: p.slug,
|
||||||
chapter: p.chapter,
|
chapter: p.chapter,
|
||||||
updated: p.updated,
|
updated: p.updated,
|
||||||
title: bookMap.get(p.slug)?.title ?? p.slug,
|
title: bookMap.get(p.slug)?.title ?? p.slug,
|
||||||
cover: bookMap.get(p.slug)?.cover ?? null
|
cover: bookMap.get(p.slug)?.cover ?? null
|
||||||
}));
|
}));
|
||||||
} catch (e) {
|
|
||||||
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(e) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: fetch avatar/email/polarCustomerId (getUserByUsername → resolveAvatarUrl)
|
||||||
|
async function fetchUserRecord() {
|
||||||
|
const record = await getUserByUsername(locals.user!.username);
|
||||||
|
const avatarUrl = await resolveAvatarUrl(locals.user!.id, record?.avatar_url);
|
||||||
|
return {
|
||||||
|
avatarUrl,
|
||||||
|
email: record?.email ?? null,
|
||||||
|
polarCustomerId: record?.polar_customer_id ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all three independent groups concurrently
|
||||||
|
const [userRecord, sessionsResult, statsResult, historyResult] = await Promise.allSettled([
|
||||||
|
fetchUserRecord(),
|
||||||
|
listUserSessions(locals.user.id),
|
||||||
|
getUserStats(locals.sessionId, locals.user.id),
|
||||||
|
fetchHistory()
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (userRecord.status === 'rejected')
|
||||||
|
log.warn('profile', 'avatar fetch failed (non-fatal)', { err: String(userRecord.reason) });
|
||||||
|
if (sessionsResult.status === 'rejected')
|
||||||
|
log.warn('profile', 'sessions fetch failed (non-fatal)', { err: String(sessionsResult.reason) });
|
||||||
|
if (statsResult.status === 'rejected')
|
||||||
|
log.warn('profile', 'stats fetch failed (non-fatal)', { err: String(statsResult.reason) });
|
||||||
|
if (historyResult.status === 'rejected')
|
||||||
|
log.warn('profile', 'history fetch failed (non-fatal)', { err: String(historyResult.reason) });
|
||||||
|
|
||||||
|
const { avatarUrl = null, email = null, polarCustomerId = null } =
|
||||||
|
userRecord.status === 'fulfilled' ? userRecord.value : {};
|
||||||
|
const sessions =
|
||||||
|
sessionsResult.status === 'fulfilled' ? sessionsResult.value : [];
|
||||||
|
const stats =
|
||||||
|
statsResult.status === 'fulfilled' ? statsResult.value : null;
|
||||||
|
const history =
|
||||||
|
historyResult.status === 'fulfilled' ? historyResult.value : [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: locals.user,
|
user: locals.user,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
import type { AudioMode } from '$lib/audio.svelte';
|
import type { AudioMode } from '$lib/audio.svelte';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import type { Voice } from '$lib/types';
|
|
||||||
import { cn } from '$lib/utils';
|
import { cn } from '$lib/utils';
|
||||||
import * as m from '$lib/paraglide/messages.js';
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
@@ -84,70 +83,6 @@
|
|||||||
|
|
||||||
function handleCropCancel() { cropFile = null; }
|
function handleCropCancel() { cropFile = null; }
|
||||||
|
|
||||||
// ── Voices ───────────────────────────────────────────────────────────────────
|
|
||||||
let voices = $state<Voice[]>([]);
|
|
||||||
let voicesLoaded = $state(false);
|
|
||||||
|
|
||||||
const kokoroVoices = $derived(voices.filter((v) => v.engine === 'kokoro'));
|
|
||||||
const pocketVoices = $derived(voices.filter((v) => v.engine === 'pocket-tts'));
|
|
||||||
const cfaiVoices = $derived(voices.filter((v) => v.engine === 'cfai'));
|
|
||||||
|
|
||||||
function voiceLabel(v: Voice): string {
|
|
||||||
if (v.engine === 'cfai') {
|
|
||||||
const speaker = v.id.startsWith('cfai:') ? v.id.slice(5) : v.id;
|
|
||||||
return speaker.replace(/\b\w/g, (c) => c.toUpperCase()) + (v.gender ? ` (EN ${v.gender.toUpperCase()})` : '');
|
|
||||||
}
|
|
||||||
if (v.engine === 'pocket-tts') {
|
|
||||||
const name = v.id.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
||||||
return name + (v.gender ? ` (${v.lang?.toUpperCase().replace('-','')} ${v.gender.toUpperCase()})` : '');
|
|
||||||
}
|
|
||||||
// Kokoro: "af_bella" → "Bella (US F)"
|
|
||||||
const langMap: Record<string, string> = {
|
|
||||||
af:'US', am:'US', bf:'UK', bm:'UK',
|
|
||||||
ef:'ES', em:'ES', ff:'FR',
|
|
||||||
hf:'IN', hm:'IN', 'if':'IT', im:'IT',
|
|
||||||
jf:'JP', jm:'JP', pf:'PT', pm:'PT', zf:'ZH', zm:'ZH',
|
|
||||||
};
|
|
||||||
const prefix = v.id.slice(0, 2);
|
|
||||||
const name = v.id.slice(3).replace(/^v0/, '').replace(/^([a-z])/, (c) => c.toUpperCase());
|
|
||||||
const lang = langMap[prefix] ?? prefix.toUpperCase();
|
|
||||||
const gender = v.gender ? v.gender.toUpperCase() : '?';
|
|
||||||
return `${name} (${lang} ${gender})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
fetch('/api/voices')
|
|
||||||
.then((r) => r.json())
|
|
||||||
.then((d: { voices: Voice[] }) => { voices = d.voices ?? []; voicesLoaded = true; })
|
|
||||||
.catch(() => { voicesLoaded = true; });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Voice sample playback
|
|
||||||
let samplePlayingVoice = $state<string | null>(null);
|
|
||||||
let sampleAudio = $state<HTMLAudioElement | null>(null);
|
|
||||||
|
|
||||||
function stopSample() {
|
|
||||||
if (sampleAudio) { sampleAudio.pause(); sampleAudio.src = ''; sampleAudio = null; }
|
|
||||||
samplePlayingVoice = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleSample(voiceId: string) {
|
|
||||||
if (samplePlayingVoice === voiceId) { stopSample(); return; }
|
|
||||||
stopSample();
|
|
||||||
samplePlayingVoice = voiceId;
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/presign/voice-sample?voice=${encodeURIComponent(voiceId)}`);
|
|
||||||
if (res.status === 404) { samplePlayingVoice = null; return; }
|
|
||||||
if (!res.ok) throw new Error();
|
|
||||||
const { url } = await res.json() as { url: string };
|
|
||||||
const audio = new Audio(url);
|
|
||||||
sampleAudio = audio;
|
|
||||||
audio.onended = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
|
||||||
audio.onerror = () => { if (samplePlayingVoice === voiceId) stopSample(); };
|
|
||||||
await audio.play();
|
|
||||||
} catch { samplePlayingVoice = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Settings state ────────────────────────────────────────────────────────────
|
// ── Settings state ────────────────────────────────────────────────────────────
|
||||||
// All changes are written directly into audioStore / theme context.
|
// All changes are written directly into audioStore / theme context.
|
||||||
// The layout's debounced $effect owns the single PUT /api/settings call.
|
// The layout's debounced $effect owns the single PUT /api/settings call.
|
||||||
@@ -549,64 +484,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TTS voice -->
|
|
||||||
<div class="px-6 py-5 space-y-3">
|
|
||||||
<p class="text-sm font-medium text-(--color-text)">{m.profile_tts_voice()}</p>
|
|
||||||
{#if !voicesLoaded}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each [1,2,3] as _}
|
|
||||||
<div class="h-10 bg-(--color-surface-3) rounded-lg animate-pulse"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else if voices.length === 0}
|
|
||||||
<p class="text-sm text-(--color-muted) italic">No voices available.</p>
|
|
||||||
{:else}
|
|
||||||
{#each [
|
|
||||||
{ label: 'Kokoro (GPU)', voices: kokoroVoices },
|
|
||||||
{ label: 'Pocket TTS (CPU)', voices: pocketVoices },
|
|
||||||
{ label: 'Cloudflare AI', voices: cfaiVoices },
|
|
||||||
].filter(g => g.voices.length > 0) as group}
|
|
||||||
<div>
|
|
||||||
<p class="text-[10px] font-semibold text-(--color-muted) uppercase tracking-widest mb-2">{group.label}</p>
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
|
|
||||||
{#each group.voices as v (v.id)}
|
|
||||||
{@const isSelected = audioStore.voice === v.id}
|
|
||||||
{@const isPlaying = samplePlayingVoice === v.id}
|
|
||||||
<div
|
|
||||||
role="option"
|
|
||||||
aria-selected={isSelected}
|
|
||||||
tabindex="0"
|
|
||||||
onclick={() => { audioStore.voice = v.id; }}
|
|
||||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); audioStore.voice = v.id; } }}
|
|
||||||
class={cn(
|
|
||||||
'flex items-center justify-between gap-2 px-3 py-2.5 rounded-lg border text-sm transition-colors cursor-pointer select-none',
|
|
||||||
isSelected
|
|
||||||
? 'border-(--color-brand) bg-(--color-brand)/10 text-(--color-brand)'
|
|
||||||
: 'border-(--color-border) bg-(--color-surface-3) text-(--color-text) hover:border-(--color-brand)/40'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span class="truncate font-medium">{voiceLabel(v)}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={(e) => { e.stopPropagation(); toggleSample(v.id); }}
|
|
||||||
class={cn(
|
|
||||||
'shrink-0 px-2 py-0.5 rounded text-xs font-medium transition-colors',
|
|
||||||
isPlaying
|
|
||||||
? 'bg-(--color-brand) text-(--color-surface)'
|
|
||||||
: 'bg-(--color-surface-2) text-(--color-muted) hover:text-(--color-text)'
|
|
||||||
)}
|
|
||||||
title={isPlaying ? 'Stop sample' : 'Play sample'}
|
|
||||||
>
|
|
||||||
{isPlaying ? 'Stop' : 'Play'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Playback speed -->
|
<!-- Playback speed -->
|
||||||
<div class="px-6 py-5 space-y-3">
|
<div class="px-6 py-5 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user