From 5a019bd35cf7bc95c6b8a0b0fc4e4361c1e3a0bb Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 10 Aug 2026 14:00:19 +0400 Subject: [PATCH] perf(vibe): stop building a plasma and reading the library on a phone Two things made the Vibe page crawl on mobile. The ambient aura pushed three counter-rotating layers through an SVG turbulence and displacement pass whose noise field was animated in SMIL. SVG filters rasterise on the CPU, so that regenerated the whole field every frame, under a blur, across a 420x380 element. Below the breakpoint the filter is no longer mounted and the layers churn behind a plain blur instead. A phone gets the motion, not the noise pass. The start screen read all 5,000 tracks to show fifty of them. The sample is now drawn in the database, which keeps Surprise me uniform over the whole library while sending only what is on screen. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6 --- backend/src/routes/library.routes.ts | 7 +++ backend/src/services/db.service.ts | 27 ++++++++++ frontend/src/components/VibeAura.test.tsx | 31 ++++++++++- frontend/src/components/VibeAura.tsx | 63 ++++++++++++++--------- frontend/src/hooks/useMediaQuery.ts | 27 ++++++++++ frontend/src/index.css | 9 +++- frontend/src/pages/Vibe.tsx | 34 +++++------- frontend/src/services/trackService.ts | 6 +++ 8 files changed, 157 insertions(+), 47 deletions(-) create mode 100644 frontend/src/hooks/useMediaQuery.ts diff --git a/backend/src/routes/library.routes.ts b/backend/src/routes/library.routes.ts index 6d0cf63..da8bd85 100644 --- a/backend/src/routes/library.routes.ts +++ b/backend/src/routes/library.routes.ts @@ -16,6 +16,13 @@ export default async function libraryRoutes(fastify: FastifyInstance, options: { return tracks; }); + // Seeds for the Vibe start screen. Static path, so it is matched ahead of + // /tracks/:trackId. + fastify.get('/tracks/seeds', async (request) => { + const query = request.query as any; + return await dbService.getSeedTracks(query.limit ? parseInt(query.limit) : undefined); + }); + fastify.get('/artists', async (request) => { const query = request.query as any; return await dbService.getArtists({ diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index b0e0311..73f8409 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -211,6 +211,33 @@ export class DbService { return this.attachArtists(res.rows as Track[]); } + /** + * A random handful of playable tracks, with the size of the pool they came + * from. The Vibe start screen offers seeds; it used to read the whole library + * to pick fifty of them, which on a phone is megabytes of JSON to draw one + * list. Sampling in the database keeps "Surprise me" uniform over everything + * playable while sending only what is shown. + */ + async getSeedTracks(limit = 50): Promise<{ total: number; tracks: Track[] }> { + const playable = `t.state NOT IN ('HIDDEN', 'DELETED')`; + const [sample, total] = await Promise.all([ + this.pgClient.query( + `SELECT t.*, al.artwork_id + FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + WHERE ${playable} + ORDER BY RANDOM() + LIMIT $1`, + [Math.min(Math.max(1, limit), 200)] + ), + this.pgClient.query(`SELECT COUNT(*)::int AS total FROM tracks t WHERE ${playable}`), + ]); + return { + total: total.rows[0]?.total ?? 0, + tracks: await this.attachArtists(sample.rows as Track[]), + }; + } + /** Attach artists array (from track_artists join) to a list of tracks. */ private async attachArtists(tracks: Track[]): Promise { if (tracks.length === 0) return tracks; diff --git a/frontend/src/components/VibeAura.test.tsx b/frontend/src/components/VibeAura.test.tsx index 9e8ec9d..809cce0 100644 --- a/frontend/src/components/VibeAura.test.tsx +++ b/frontend/src/components/VibeAura.test.tsx @@ -1,8 +1,37 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { VibeAura } from './VibeAura'; +function stubMatchMedia(matches: boolean) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + })); +} + describe('VibeAura', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('leaves the displacement filter off a narrow viewport', () => { + stubMatchMedia(false); + const { container } = render(); + + expect(container.querySelector('filter#vibe-plasma')).toBeNull(); + expect(container.querySelector('.vibe-aura-stack-plasma')).toBeNull(); + // The layers themselves stay: a phone gets the churn, not the noise pass. + expect(container.querySelectorAll('.vibe-aura-layer')).toHaveLength(3); + }); + + it('mounts the filter and names it on the stack once there is room for it', () => { + stubMatchMedia(true); + const { container } = render(); + + expect(container.querySelector('filter#vibe-plasma')).not.toBeNull(); + expect(container.querySelector('.vibe-aura-stack-plasma')).not.toBeNull(); + }); + it('turns the live recommendation profile into an accessible ambient state', () => { render(); diff --git a/frontend/src/components/VibeAura.tsx b/frontend/src/components/VibeAura.tsx index 2d380e7..bf553ef 100644 --- a/frontend/src/components/VibeAura.tsx +++ b/frontend/src/components/VibeAura.tsx @@ -1,4 +1,5 @@ import type { CSSProperties } from 'react'; +import { useMediaQuery } from '../hooks/useMediaQuery'; export interface VibeProfile { energy?: number; @@ -19,6 +20,9 @@ function describeProfile(energy: number, novelty: number) { /** An ambient representation of the live recommendation profile. */ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; ambient?: boolean }) { + // The displacement pass is desktop-only; see the comment in the ambient + // branch below. Same breakpoint the layout already uses for this element. + const plasma = useMediaQuery('(min-width: 640px)'); const energy = clamp(profile.energy, 0.5); const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3); const { energyLabel, discoveryLabel } = describeProfile(energy, novelty); @@ -58,6 +62,13 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a // Displacing clean gradients by animated fractal noise is what separates a // plasma from a blurred blob. baseFrequency is animated in SMIL rather than // CSS because no CSS property reaches inside an SVG filter primitive. + // + // That pass is also why the Vibe page crawled on a phone. An SVG filter is + // rasterised on the CPU, and animating baseFrequency regenerates the whole + // turbulence field every frame — for a 420x380 element, under a blur, over + // three counter-rotating layers. Below the breakpoint the filter is not + // mounted at all and the same layers churn behind a plain blur, which is + // the difference between a heavy effect and a scrolling page. return (
- - - - + + + + + - - - - -
+ + + )} +
diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000..bce4bbf --- /dev/null +++ b/frontend/src/hooks/useMediaQuery.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +/** + * Track a CSS media query from React, for the cases where a media query in the + * stylesheet is not enough — deciding whether to mount an element at all, not + * just how to paint it. + * + * Returns false where `matchMedia` is missing (server render, jsdom), which + * makes the cheaper branch the default everywhere it cannot be measured. + */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia(query).matches; + }); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const list = window.matchMedia(query); + const update = () => setMatches(list.matches); + update(); + list.addEventListener('change', update); + return () => list.removeEventListener('change', update); + }, [query]); + + return matches; +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 784d3a9..d4f862a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -277,6 +277,12 @@ html { scroll-behavior: smooth; } .vibe-aura-stack { position: absolute; inset: 0; + /* Blur alone is the cheap variant, and the only one phones get. The + displacement pass is added by .vibe-aura-stack-plasma, which VibeAura + applies only when it has also mounted the filter it names. */ + filter: blur(14px); +} +.vibe-aura-stack-plasma { filter: url(#vibe-plasma) blur(14px); } .vibe-aura-layer { @@ -333,7 +339,8 @@ html { scroll-behavior: smooth; } animation: vibe-aura-breathe var(--vibe-pulse) ease-in-out infinite alternate; } @media (min-width: 640px) { - .vibe-aura-stack { filter: url(#vibe-plasma) blur(20px); } + .vibe-aura-stack { filter: blur(20px); } + .vibe-aura-stack-plasma { filter: url(#vibe-plasma) blur(20px); } } @media (prefers-reduced-motion: reduce) { .vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark, diff --git a/frontend/src/pages/Vibe.tsx b/frontend/src/pages/Vibe.tsx index f747415..7abdbed 100644 --- a/frontend/src/pages/Vibe.tsx +++ b/frontend/src/pages/Vibe.tsx @@ -14,15 +14,6 @@ import { VibeAura } from '../components/VibeAura'; const SEED_LIST_SIZE = 50; -function sampleTracks(tracks: Track[], count: number): Track[] { - const sampled = [...tracks]; - for (let index = sampled.length - 1; index > 0; index--) { - const pick = Math.floor(Math.random() * (index + 1)); - [sampled[index], sampled[pick]] = [sampled[pick], sampled[index]]; - } - return sampled.slice(0, count); -} - export default function Vibe() { const { currentTrack, queue } = usePlaybackStore(); const { @@ -37,14 +28,17 @@ export default function Vibe() { const [empty, setEmpty] = useState(false); const startingRef = useRef(false); - const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery({ + // The sample is drawn in the database and is uniform over everything + // playable, so Surprise me still reaches the whole library. Reading all 5000 + // tracks to display fifty of them was megabytes of JSON for a phone to parse + // before this page could paint. + const { data: seeds, isLoading: libraryLoading } = useQuery({ queryKey: ['library-seed'], - // Fetch the eligible library once so Surprise me is not restricted to the - // most-played 50 tracks. The backend excludes hidden/deleted tracks. - queryFn: () => trackService.listTracks({ limit: 5000, sort_by: 'title', order: 'ASC' }), + queryFn: () => trackService.listSeedTracks(SEED_LIST_SIZE), enabled: !activeSessionId, }); - const seedTracks = useMemo(() => sampleTracks(libraryTracks, SEED_LIST_SIZE), [libraryTracks]); + const seedTracks = useMemo(() => seeds?.tracks ?? [], [seeds]); + const eligibleCount = seeds?.total ?? 0; const startSession = useCallback( async (seed: Track) => { @@ -74,10 +68,10 @@ export default function Vibe() { }, [currentTrack, startSession]); const surpriseMe = useCallback(() => { - if (libraryTracks.length === 0) return; - const seed = libraryTracks[Math.floor(Math.random() * libraryTracks.length)]; + if (seedTracks.length === 0) return; + const seed = seedTracks[Math.floor(Math.random() * seedTracks.length)]; void startSession(seed); - }, [libraryTracks, startSession]); + }, [seedTracks, startSession]); const handleDislike = useCallback(() => { if (currentTrack) { @@ -121,7 +115,7 @@ export default function Vibe() { {error && ( @@ -147,7 +141,7 @@ export default function Vibe() {