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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
This commit is contained in:
kami
2026-08-10 14:00:19 +04:00
parent 0a01085ed0
commit 5a019bd35c
8 changed files with 157 additions and 47 deletions
+7
View File
@@ -16,6 +16,13 @@ export default async function libraryRoutes(fastify: FastifyInstance, options: {
return tracks; 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) => { fastify.get('/artists', async (request) => {
const query = request.query as any; const query = request.query as any;
return await dbService.getArtists({ return await dbService.getArtists({
+27
View File
@@ -211,6 +211,33 @@ export class DbService {
return this.attachArtists(res.rows as Track[]); 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. */ /** Attach artists array (from track_artists join) to a list of tracks. */
private async attachArtists(tracks: Track[]): Promise<Track[]> { private async attachArtists(tracks: Track[]): Promise<Track[]> {
if (tracks.length === 0) return tracks; if (tracks.length === 0) return tracks;
+30 -1
View File
@@ -1,8 +1,37 @@
import { render, screen } from '@testing-library/react'; 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'; import { VibeAura } from './VibeAura';
function stubMatchMedia(matches: boolean) {
vi.stubGlobal('matchMedia', (query: string) => ({
matches,
media: query,
addEventListener: () => {},
removeEventListener: () => {},
}));
}
describe('VibeAura', () => { describe('VibeAura', () => {
afterEach(() => vi.unstubAllGlobals());
it('leaves the displacement filter off a narrow viewport', () => {
stubMatchMedia(false);
const { container } = render(<VibeAura profile={{ energy: 0.8 }} ambient />);
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(<VibeAura profile={{ energy: 0.8 }} ambient />);
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', () => { it('turns the live recommendation profile into an accessible ambient state', () => {
render(<VibeAura profile={{ energy: 0.82, noveltyHunger: 0.78 }} />); render(<VibeAura profile={{ energy: 0.82, noveltyHunger: 0.78 }} />);
+38 -25
View File
@@ -1,4 +1,5 @@
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useMediaQuery } from '../hooks/useMediaQuery';
export interface VibeProfile { export interface VibeProfile {
energy?: number; energy?: number;
@@ -19,6 +20,9 @@ function describeProfile(energy: number, novelty: number) {
/** An ambient representation of the live recommendation profile. */ /** An ambient representation of the live recommendation profile. */
export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; ambient?: boolean }) { 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 energy = clamp(profile.energy, 0.5);
const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3); const novelty = clamp(profile.noveltyHunger ?? profile.explorationCoefficient, 0.3);
const { energyLabel, discoveryLabel } = describeProfile(energy, novelty); 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 // Displacing clean gradients by animated fractal noise is what separates a
// plasma from a blurred blob. baseFrequency is animated in SMIL rather than // plasma from a blurred blob. baseFrequency is animated in SMIL rather than
// CSS because no CSS property reaches inside an SVG filter primitive. // 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 ( return (
<div <div
className="vibe-aura-blob pointer-events-none absolute z-0 h-[380px] w-[420px] -translate-x-1/2 -translate-y-1/2 opacity-85 mix-blend-screen sm:h-[560px] sm:w-[660px] sm:opacity-95" className="vibe-aura-blob pointer-events-none absolute z-0 h-[380px] w-[420px] -translate-x-1/2 -translate-y-1/2 opacity-85 mix-blend-screen sm:h-[560px] sm:w-[660px] sm:opacity-95"
@@ -65,32 +76,34 @@ export function VibeAura({ profile, ambient = false }: { profile: VibeProfile; a
role="img" role="img"
aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`} aria-label={`Current Vibe: ${energyLabel} energy and ${discoveryLabel} discovery`}
> >
<svg aria-hidden className="absolute h-0 w-0"> {plasma && (
<filter id="vibe-plasma" x="-30%" y="-30%" width="160%" height="160%"> <svg aria-hidden className="absolute h-0 w-0">
<feTurbulence <filter id="vibe-plasma" x="-30%" y="-30%" width="160%" height="160%">
type="fractalNoise" <feTurbulence
baseFrequency="0.009 0.014" type="fractalNoise"
numOctaves={3} baseFrequency="0.009 0.014"
seed={7} numOctaves={3}
result="noise" seed={7}
> result="noise"
<animate >
attributeName="baseFrequency" <animate
dur={`${(18 - energy * 8).toFixed(1)}s`} attributeName="baseFrequency"
values="0.009 0.014; 0.021 0.007; 0.009 0.014" dur={`${(18 - energy * 8).toFixed(1)}s`}
repeatCount="indefinite" values="0.009 0.014; 0.021 0.007; 0.009 0.014"
repeatCount="indefinite"
/>
</feTurbulence>
<feDisplacementMap
in="SourceGraphic"
in2="noise"
scale={String(Math.round(46 + energy * 70))}
xChannelSelector="R"
yChannelSelector="G"
/> />
</feTurbulence> </filter>
<feDisplacementMap </svg>
in="SourceGraphic" )}
in2="noise" <div className={plasma ? 'vibe-aura-stack vibe-aura-stack-plasma' : 'vibe-aura-stack'}>
scale={String(Math.round(46 + energy * 70))}
xChannelSelector="R"
yChannelSelector="G"
/>
</filter>
</svg>
<div className="vibe-aura-stack">
<div className="vibe-aura-layer vibe-aura-rays-layer" /> <div className="vibe-aura-layer vibe-aura-rays-layer" />
<div className="vibe-aura-layer vibe-aura-swirl-layer" /> <div className="vibe-aura-layer vibe-aura-swirl-layer" />
<div className="vibe-aura-layer vibe-aura-core-layer" /> <div className="vibe-aura-layer vibe-aura-core-layer" />
+27
View File
@@ -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;
}
+8 -1
View File
@@ -277,6 +277,12 @@ html { scroll-behavior: smooth; }
.vibe-aura-stack { .vibe-aura-stack {
position: absolute; position: absolute;
inset: 0; 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); filter: url(#vibe-plasma) blur(14px);
} }
.vibe-aura-layer { .vibe-aura-layer {
@@ -333,7 +339,8 @@ html { scroll-behavior: smooth; }
animation: vibe-aura-breathe var(--vibe-pulse) ease-in-out infinite alternate; animation: vibe-aura-breathe var(--vibe-pulse) ease-in-out infinite alternate;
} }
@media (min-width: 640px) { @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) { @media (prefers-reduced-motion: reduce) {
.vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark, .vibe-aura-halo, .vibe-aura-core, .vibe-aura-orbit, .vibe-aura-spark,
+14 -20
View File
@@ -14,15 +14,6 @@ import { VibeAura } from '../components/VibeAura';
const SEED_LIST_SIZE = 50; 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() { export default function Vibe() {
const { currentTrack, queue } = usePlaybackStore(); const { currentTrack, queue } = usePlaybackStore();
const { const {
@@ -37,14 +28,17 @@ export default function Vibe() {
const [empty, setEmpty] = useState(false); const [empty, setEmpty] = useState(false);
const startingRef = useRef(false); const startingRef = useRef(false);
const { data: libraryTracks = [], isLoading: libraryLoading } = useQuery<Track[]>({ // 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'], queryKey: ['library-seed'],
// Fetch the eligible library once so Surprise me is not restricted to the queryFn: () => trackService.listSeedTracks(SEED_LIST_SIZE),
// most-played 50 tracks. The backend excludes hidden/deleted tracks.
queryFn: () => trackService.listTracks({ limit: 5000, sort_by: 'title', order: 'ASC' }),
enabled: !activeSessionId, 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( const startSession = useCallback(
async (seed: Track) => { async (seed: Track) => {
@@ -74,10 +68,10 @@ export default function Vibe() {
}, [currentTrack, startSession]); }, [currentTrack, startSession]);
const surpriseMe = useCallback(() => { const surpriseMe = useCallback(() => {
if (libraryTracks.length === 0) return; if (seedTracks.length === 0) return;
const seed = libraryTracks[Math.floor(Math.random() * libraryTracks.length)]; const seed = seedTracks[Math.floor(Math.random() * seedTracks.length)];
void startSession(seed); void startSession(seed);
}, [libraryTracks, startSession]); }, [seedTracks, startSession]);
const handleDislike = useCallback(() => { const handleDislike = useCallback(() => {
if (currentTrack) { if (currentTrack) {
@@ -121,7 +115,7 @@ export default function Vibe() {
<PageHeader <PageHeader
title="Start a Vibe" title="Start a Vibe"
subtitle="Pick a seed. Playback starts at once and the queue re-plans as you keep or skip." subtitle="Pick a seed. Playback starts at once and the queue re-plans as you keep or skip."
meta={libraryTracks.length ? `${libraryTracks.length.toLocaleString()} tracks eligible` : undefined} meta={eligibleCount ? `${eligibleCount.toLocaleString()} tracks eligible` : undefined}
/> />
{error && ( {error && (
@@ -147,7 +141,7 @@ export default function Vibe() {
<button <button
onClick={surpriseMe} onClick={surpriseMe}
disabled={starting || libraryLoading || libraryTracks.length === 0} disabled={starting || libraryLoading || seedTracks.length === 0}
className="flex h-16 w-full items-center gap-3 rounded-lg border border-border bg-surface0/60 px-3 text-left transition-colors hover:bg-surface1 disabled:opacity-60" className="flex h-16 w-full items-center gap-3 rounded-lg border border-border bg-surface0/60 px-3 text-left transition-colors hover:bg-surface1 disabled:opacity-60"
> >
<Shuffle size={18} className="flex-none text-text" /> <Shuffle size={18} className="flex-none text-text" />
@@ -156,7 +150,7 @@ export default function Vibe() {
<div className="truncate text-xs text-secondary"> <div className="truncate text-xs text-secondary">
{libraryLoading {libraryLoading
? 'reading library…' ? 'reading library…'
: libraryTracks.length === 0 : seedTracks.length === 0
? 'No eligible tracks to seed from.' ? 'No eligible tracks to seed from.'
: 'Random seed from the whole library.'} : 'Random seed from the whole library.'}
</div> </div>
+6
View File
@@ -16,6 +16,12 @@ export const trackService = {
return res.data; return res.data;
}, },
// GET /api/tracks/seeds — a random playable sample and the size of the pool.
async listSeedTracks(limit = 50): Promise<{ total: number; tracks: Track[] }> {
const res = await api.get<{ total: number; tracks: Track[] }>('/tracks/seeds', { params: { limit } });
return res.data;
},
// GET /api/tracks/:id // GET /api/tracks/:id
async getTrack(id: string): Promise<Track> { async getTrack(id: string): Promise<Track> {
const res = await api.get<Track>(`/tracks/${id}`); const res = await api.get<Track>(`/tracks/${id}`);