From ada7de466a80d579178cb43634d88bdf3331ab3b Mon Sep 17 00:00:00 2001 From: root Date: Sat, 11 Apr 2026 10:41:35 +0500 Subject: [PATCH] perf: remove voice picker from profile, parallelize server load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ui/src/routes/profile/+page.server.ts | 73 ++++++++------- ui/src/routes/profile/+page.svelte | 123 -------------------------- 2 files changed, 41 insertions(+), 155 deletions(-) diff --git a/ui/src/routes/profile/+page.server.ts b/ui/src/routes/profile/+page.server.ts index 3c59c7c..a508a80 100644 --- a/ui/src/routes/profile/+page.server.ts +++ b/ui/src/routes/profile/+page.server.ts @@ -15,49 +15,58 @@ export const load: PageServerLoad = async ({ locals }) => { redirect(302, '/login'); } - let sessions: Awaited> = []; - let email: string | null = null; - let polarCustomerId: string | null = null; - let stats: Awaited> | 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); + // Helper: fetch reading history (progress → books, sequential by necessity) + async function fetchHistory() { + const progress = await allProgress(locals.sessionId, locals.user!.id); const recent = progress.slice(0, 50); const books = await getBooksBySlugs(new Set(recent.map((p) => p.slug))); const bookMap = new Map(books.map((b) => [b.slug, b])); - history = recent.map((p) => ({ + return recent.map((p) => ({ slug: p.slug, chapter: p.chapter, updated: p.updated, title: bookMap.get(p.slug)?.title ?? p.slug, 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 { user: locals.user, avatarUrl, diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 7a24cf4..ada0abf 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -6,7 +6,6 @@ import type { AudioMode } from '$lib/audio.svelte'; import { browser } from '$app/environment'; import { page } from '$app/state'; - import type { Voice } from '$lib/types'; import { cn } from '$lib/utils'; import * as m from '$lib/paraglide/messages.js'; @@ -84,70 +83,6 @@ function handleCropCancel() { cropFile = null; } - // ── Voices ─────────────────────────────────────────────────────────────────── - let voices = $state([]); - 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 = { - 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(null); - let sampleAudio = $state(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 ──────────────────────────────────────────────────────────── // All changes are written directly into audioStore / theme context. // The layout's debounced $effect owns the single PUT /api/settings call. @@ -549,64 +484,6 @@ - -
-

{m.profile_tts_voice()}

- {#if !voicesLoaded} -
- {#each [1,2,3] as _} -
- {/each} -
- {:else if voices.length === 0} -

No voices available.

- {: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} -
-

{group.label}

-
- {#each group.voices as v (v.id)} - {@const isSelected = audioStore.voice === v.id} - {@const isPlaying = samplePlayingVoice === v.id} -
{ 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' - )} - > - {voiceLabel(v)} - -
- {/each} -
-
- {/each} - {/if} -
-