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() {