From c82db91180ad13b75e20dfadeaac728377e831ba Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 20:51:51 -0500 Subject: [PATCH 01/15] Beat engine and music-reactive backdrop prototype (MAD-772) A client beat engine turns the playback position into CSS variables (--beat, --downbeat, --swell, --energy, bands) on the elements that use them. The backdrop breathes once a bar, the now-playing art lifts and blooms, the mini-player's glass catches light on the one, and the equalizer follows the beat. Each song gets a backdrop scene (mesh, horizon, ripples, aurora, embers) drawn in its palette. A dev-only beat lab sets the grid by tap tempo until the server sends beat maps. Co-Authored-By: Claude Opus 5.5 --- web/src/components/equalizer.tsx | 10 +- web/src/components/shell/album-backdrop.tsx | 14 +- web/src/components/shell/app-shell.tsx | 2 + web/src/components/shell/backdrop-scene.tsx | 271 +++++++++++++++ web/src/components/shell/beat-lab.tsx | 183 ++++++++++ web/src/components/shell/mini-player.tsx | 5 + .../components/shell/now-playing-sheet.tsx | 18 +- web/src/hooks/use-beat.ts | 11 + web/src/hooks/use-scene.ts | 22 ++ web/src/index.css | 52 +++ web/src/lib/beat.ts | 328 ++++++++++++++++++ 11 files changed, 905 insertions(+), 11 deletions(-) create mode 100644 web/src/components/shell/backdrop-scene.tsx create mode 100644 web/src/components/shell/beat-lab.tsx create mode 100644 web/src/hooks/use-beat.ts create mode 100644 web/src/hooks/use-scene.ts create mode 100644 web/src/lib/beat.ts diff --git a/web/src/components/equalizer.tsx b/web/src/components/equalizer.tsx index 0e03c57..c378b55 100644 --- a/web/src/components/equalizer.tsx +++ b/web/src/components/equalizer.tsx @@ -1,3 +1,4 @@ +import { useBeat } from '@/hooks/use-beat' import { cn } from '@/lib/utils' // Each bar's period and offset differ, so the pattern never visibly repeats. @@ -8,10 +9,14 @@ const bars = [ { duration: '0.95s', delay: '-0.6s' }, ] -/** Bouncing bars: audio is playing here. Settles low while paused. */ +/** + * Bouncing bars: audio is playing here. Settles low while paused. While the + * beat engine is live, each bar follows one of its bands instead. + */ export function Equalizer({ playing, className }: { playing: boolean; className?: string }) { + const beat = useBeat() return ( - + {bars.map((b, i) => ( ({ drift: true }) return ( -
-
+
+
-
+
{src && ( @@ -36,6 +43,7 @@ export function AlbumBackdrop({ src: next, className }: { src?: string; classNam )}
+
) } diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx index a40e49f..3996b84 100644 --- a/web/src/components/shell/app-shell.tsx +++ b/web/src/components/shell/app-shell.tsx @@ -8,6 +8,7 @@ import { usePlayer } from '@/lib/now-playing' import { useOnline } from '@/lib/pwa' import { Toaster } from '@/components/toaster' import { AlbumBackdrop } from './album-backdrop' +import { BeatLab } from './beat-lab' import { BottomNav } from './bottom-nav' import { MiniPlayer } from './mini-player' import { NowPlayingSheet } from './now-playing-sheet' @@ -41,6 +42,7 @@ export function AppShell() {
+ {import.meta.env.DEV && }
) diff --git a/web/src/components/shell/backdrop-scene.tsx b/web/src/components/shell/backdrop-scene.tsx new file mode 100644 index 0000000..a998ba5 --- /dev/null +++ b/web/src/components/shell/backdrop-scene.tsx @@ -0,0 +1,271 @@ +import { AnimatePresence, motion } from 'motion/react' +import { useEffect, useRef } from 'react' +import { useScene, type Scene } from '@/hooks/use-scene' +import { onBeatFrame, type BeatFrame } from '@/lib/beat' + +/* + * Backdrop scenes (MAD-772 prototype): a canvas over the palette mesh that + * draws something music-shaped in the art's colors. Each song gets one; the + * pick is in use-scene.ts. + * + * Drawn at half the screen's resolution, then softened with a blur: glow, + * not pixels, and a phone fills a quarter of the pixels a frame. + */ + +const SCALE = 1 / 2 + +type Colors = { vibrant: string; dominant: string; muted: string; light: string } + +type Painter = (ctx: CanvasRenderingContext2D, w: number, h: number, f: BeatFrame, c: Colors) => void + +/** The scene layer, crossfading from one song's scene to the next. */ +export function BackdropScene() { + const scene = useScene() + return ( + + + + + + ) +} + +function SceneCanvas({ paint }: { paint: () => Painter }) { + const ref = useRef(null) + + useEffect(() => { + const canvas = ref.current + const ctx = canvas?.getContext('2d') + if (!canvas || !ctx) return + const draw = paint() + + const fit = () => { + canvas.width = Math.max(1, Math.round(canvas.clientWidth * SCALE)) + canvas.height = Math.max(1, Math.round(canvas.clientHeight * SCALE)) + } + fit() + const ro = new ResizeObserver(fit) + ro.observe(canvas) + + // The palette eases between songs; follow it a few times a second. + let colors = readColors(canvas) + let readAt = 0 + + const off = onBeatFrame((f) => { + if (f.now - readAt > 300) { + colors = readColors(canvas) + readAt = f.now + } + ctx.clearRect(0, 0, canvas.width, canvas.height) + if (f.presence < 0.002) return + ctx.save() + ctx.globalAlpha = f.presence + draw(ctx, canvas.width, canvas.height, f, colors) + ctx.restore() + }) + return () => { + off() + ro.disconnect() + } + }, [paint]) + + return +} + +function readColors(el: HTMLElement): Colors { + const s = getComputedStyle(el) + const v = (name: string) => s.getPropertyValue(name).trim() || 'white' + return { vibrant: v('--pal-vibrant'), dominant: v('--pal-dominant'), muted: v('--pal-muted'), light: v('--pal-light') } +} + +/** Eases a value: fast up (attack), slower down (release). */ +function follow(from: number, to: number, dt: number, attack: number, release: number) { + const ms = to > from ? attack : release + return from + (to - from) * (1 - Math.exp(-dt / (ms / 3))) +} + +// --- Scenes ------------------------------------------------------------------ +// Each is a factory, so every canvas gets its own state. + +/** A background equalizer: soft columns along the bottom, bass in the middle, treble at the edges. */ +function horizon(): Painter { + const N = 32 + const level = new Float32Array(N) + return (ctx, w, h, f, c) => { + const [b0, b1, b2, b3] = f.bands + const t = f.now / 1000 + const gap = w / N + const grad = ctx.createLinearGradient(0, h, 0, h * 0.35) + grad.addColorStop(0, c.dominant) + grad.addColorStop(0.6, c.vibrant) + grad.addColorStop(1, c.light) + ctx.fillStyle = grad + for (let i = 0; i < N; i++) { + const x = (i + 0.5) / N + const d = Math.abs(x - 0.5) * 2 // 0 middle, 1 edges + const shape = + b0 * (1 - d) ** 1.6 + + b1 * 0.7 * bump(d, 0.35, 0.25) + + b2 * 0.8 * bump(d, 0.62, 0.22) + + b3 * 0.6 * d ** 1.4 + // Each column wanders on its own, more when the song is loud. + const wander = f.energy * 0.22 * (0.5 + 0.5 * Math.sin(t * (1.3 + (i % 7) * 0.31) + i * 1.7)) + level[i] = follow(level[i], Math.min(1, shape + wander), f.dt, 50, 320) + const bh = h * (0.04 + level[i] * 0.5) + const bw = gap * 0.62 + roundRect(ctx, i * gap + (gap - bw) / 2, h - bh, bw, bh + bw, bw / 2) + } + } +} + +/** One slow ring a bar, spreading from behind the art like a drop in still water. */ +function ripples(): Painter { + type Ring = { born: number; color: keyof Colors } + let rings: Ring[] = [] + let last = Number.NaN + const palette: (keyof Colors)[] = ['vibrant', 'light'] + return (ctx, w, h, f, c) => { + const bar = Math.floor(f.beatIndex / 4) + if (bar !== last) { + if (!Number.isNaN(last)) rings.push({ born: f.now, color: palette[Math.abs(bar) % palette.length] }) + last = bar + } + const life = 5200 + rings = rings.filter((r) => f.now - r.born < life) + const cx = w / 2 + const cy = h * 0.3 + const reach = Math.hypot(w, h) * 0.7 + for (const r of rings) { + const k = (f.now - r.born) / life + const ease = 1 - (1 - k) ** 2.5 + ctx.globalAlpha = f.presence * (1 - k) ** 2 * (0.35 + 0.35 * f.energy) + ctx.strokeStyle = c[r.color] + ctx.lineWidth = h * 0.02 * (1 - k * 0.5) + ctx.beginPath() + ctx.arc(cx, cy, h * 0.05 + ease * reach, 0, Math.PI * 2) + ctx.stroke() + } + } +} + +/** + * Curtains of light hanging from the top of the screen, like the real + * aurora: soft vertical rays whose hems wave slowly. They reach further + * down on the bar and ripple faster when the song is loud. + */ +function aurora(): Painter { + let phase = 0 + const curtains: { top: number; len: number; color: keyof Colors; f: number; o: number }[] = [ + // Back to front: a tall faint veil, the main curtain, then lower, + // shorter folds in the other colors, so it has depth. + { top: -0.02, len: 0.5, color: 'muted', f: 0.6, o: 5.1 }, + { top: 0.05, len: 0.38, color: 'vibrant', f: 1, o: 0 }, + { top: 0.2, len: 0.28, color: 'light', f: 1.6, o: 2.4 }, + { top: 0.34, len: 0.22, color: 'dominant', f: 2.1, o: 3.7 }, + ] + const COLS = 40 + return (ctx, w, h, f, c) => { + phase += (f.dt / 1000) * (0.12 + f.energy * 0.35) + const colW = w / COLS + for (const k of curtains) { + for (let i = 0; i <= COLS; i++) { + const x = i / COLS + // The hem waves; the rays' brightness shimmers along it. + const wave = Math.sin(x * 4 * k.f + phase + k.o) * 0.5 + Math.sin(x * 9 * k.f - phase * 1.7 + k.o) * 0.25 + const shimmer = 0.55 + 0.45 * Math.sin(x * 23 + phase * 2.3 + k.o) ** 2 + const top = (k.top + wave * 0.05) * h + const len = h * k.len * (0.75 + 0.2 * f.energy + 0.3 * f.swell) * (0.8 + 0.2 * shimmer) + const g = ctx.createLinearGradient(0, top, 0, top + len) + g.addColorStop(0, 'transparent') + g.addColorStop(0.55, c[k.color]) + g.addColorStop(1, 'transparent') + ctx.globalAlpha = f.presence * shimmer * (0.3 + 0.25 * f.swell + 0.15 * f.energy) + ctx.fillStyle = g + ctx.fillRect(i * colW - colW, top, colW * 2.2, len) + } + } + } +} + +/** + * The palette mesh, alive: big soft pools of the art's colors that wander, + * swell and brighten once a bar, and wander faster when the song is loud. + */ +function mesh(): Painter { + let phase = Math.random() * 100 + const pools: { color: keyof Colors; x: number; y: number; ax: number; ay: number; fx: number; fy: number; r: number }[] = [ + { color: 'vibrant', x: 0.3, y: 0.25, ax: 0.18, ay: 0.12, fx: 0.7, fy: 0.9, r: 0.55 }, + { color: 'light', x: 0.72, y: 0.45, ax: 0.15, ay: 0.18, fx: 0.5, fy: 0.6, r: 0.45 }, + { color: 'dominant', x: 0.45, y: 0.8, ax: 0.22, ay: 0.1, fx: 0.4, fy: 0.8, r: 0.6 }, + ] + return (ctx, w, h, f, c) => { + phase += (f.dt / 1000) * (0.08 + f.energy * 0.3) + const m = Math.max(w, h) + for (const p of pools) { + const x = (p.x + p.ax * Math.sin(phase * p.fx)) * w + const y = (p.y + p.ay * Math.cos(phase * p.fy)) * h + const r = m * p.r * (1 + 0.22 * f.swell) + const g = ctx.createRadialGradient(x, y, 0, x, y, r) + g.addColorStop(0, c[p.color]) + g.addColorStop(1, 'transparent') + ctx.globalAlpha = f.presence * (0.28 + 0.4 * f.swell + 0.12 * f.energy) + ctx.fillStyle = g + ctx.fillRect(0, 0, w, h) + } + } +} + +/** Embers rising through the glass; they glow on the beat and quicken with energy. */ +function embers(): Painter { + const N = 70 + const roles: (keyof Colors)[] = ['vibrant', 'light', 'vibrant', 'muted'] + const ps = Array.from({ length: N }, () => ({ + x: Math.random(), + y: Math.random(), + r: 0.8 + Math.random() * 1.8, + s: 0.4 + Math.random() * 0.8, + w: Math.random() * Math.PI * 2, + hit: Math.random(), + color: roles[Math.floor(Math.random() * roles.length)], + })) + return (ctx, w, h, f, c) => { + const rise = (f.dt / 1000) * (0.02 + f.energy * 0.09) + for (const p of ps) { + p.y -= rise * p.s * (1 + f.swell * 0.8) + p.w += f.dt / 1400 + if (p.y < -0.05) { + p.y = 1.05 + p.x = Math.random() + } + const x = (p.x + Math.sin(p.w) * 0.02) * w + const y = p.y * h + // Some embers flare on the beat, the rest just drift. + const flare = p.hit > 0.55 ? f.beat * (p.hit - 0.55) * 2.2 : 0 + ctx.globalAlpha = f.presence * Math.min(1, 0.35 + flare + f.energy * 0.2) + ctx.fillStyle = c[p.color] + ctx.beginPath() + ctx.arc(x, y, p.r * (1 + flare * 0.8), 0, Math.PI * 2) + ctx.fill() + } + } +} + +const painters: Record Painter> = { mesh, horizon, ripples, aurora, embers } + +function bump(x: number, at: number, width: number) { + const d = (x - at) / width + return Math.exp(-d * d) +} + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath() + ctx.roundRect(x, y, w, h, r) + ctx.fill() +} diff --git a/web/src/components/shell/beat-lab.tsx b/web/src/components/shell/beat-lab.tsx new file mode 100644 index 0000000..aa92dbd --- /dev/null +++ b/web/src/components/shell/beat-lab.tsx @@ -0,0 +1,183 @@ +import { Activity, X } from 'lucide-react' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Slider } from '@/components/ui/slider' +import { Switch } from '@/components/ui/switch' +import { useBeat } from '@/hooks/use-beat' +import { SCENES, useScene } from '@/hooks/use-scene' +import { + beatSettings, + currentGrid, + gridVersion, + nudge, + resetGrid, + scaleTempo, + shiftDownbeat, + tap, +} from '@/lib/beat' +import { usePlayer } from '@/lib/now-playing' +import { useStore } from '@/lib/store' +import { cn } from '@/lib/utils' + +/** + * Dev-only tuning for the beat prototype (MAD-772): tap along to set the + * song's grid, fake the energy, or run without music. Goes away once the + * server sends beat maps. + */ +export function BeatLab() { + const [open, setOpen] = useState(false) + const settings = useStore(beatSettings) + useStore(gridVersion) + const { nowPlaying } = usePlayer() + const [taps, setTaps] = useState(0) + const beat = useBeat() + const grid = currentGrid() + const scene = useScene() + const set = (next: Partial) => beatSettings.set((s) => ({ ...s, ...next })) + + return ( +
+ + + {open && ( +
+
+

Beat lab

+
+ + +
+
+ + + set({ level: on ? 'subtle' : 'off' })} + /> + + + set({ freeRun })} /> + + +
+
+ + {nowPlaying && !nowPlaying.paused ? 'This song' : settings.freeRun ? 'Free run' : 'Nothing playing'} + + {Math.round(grid.bpm)} BPM +
+ +

+ Start on a “one” and tap at least 4 times. Without taps a song runs at the default tempo, + out of phase. +

+
+ + + + + + + + +
+
+ +
+ + Backdrop {settings.scene === 'auto' ? `auto: ${scene}` : scene} + +
+ {['auto', ...SCENES].map((s) => ( + + ))} +
+
+ + +
+ )} +
+ ) +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ) +} + +function Dot({ v, big }: { v: string; big?: boolean }) { + return ( + + ) +} diff --git a/web/src/components/shell/mini-player.tsx b/web/src/components/shell/mini-player.tsx index 56534cc..17b7685 100644 --- a/web/src/components/shell/mini-player.tsx +++ b/web/src/components/shell/mini-player.tsx @@ -2,6 +2,7 @@ import { Link } from '@tanstack/react-router' import { AnimatePresence, motion } from 'motion/react' import { Artwork } from '@/components/artwork' import { Button } from '@/components/ui/button' +import { useBeat } from '@/hooks/use-beat' import { usePosition } from '@/hooks/use-position' import { laneStyle } from '@/lib/lane' import { spring } from '@/lib/motion' @@ -16,6 +17,7 @@ import { PlayPauseButton, SkipButton } from './player-controls' export function MiniPlayer({ expanded, onExpand }: { expanded: boolean; onExpand: () => void }) { const { nowPlaying: np, commands } = usePlayer() const position = usePosition(np) + const beat = useBeat() if (!np) { return ( @@ -36,9 +38,12 @@ export function MiniPlayer({ expanded, onExpand }: { expanded: boolean; onExpand return (
+ {/* Light catches the glass on the one. */} +
{/* The whole bar opens now-playing; the buttons sit above it. */}
- {import.meta.env.DEV && }
) diff --git a/web/src/components/shell/backdrop-scene.tsx b/web/src/components/shell/backdrop-scene.tsx index a998ba5..33befbc 100644 --- a/web/src/components/shell/backdrop-scene.tsx +++ b/web/src/components/shell/backdrop-scene.tsx @@ -94,14 +94,14 @@ function follow(from: number, to: number, dt: number, attack: number, release: n // --- Scenes ------------------------------------------------------------------ // Each is a factory, so every canvas gets its own state. -/** A background equalizer: soft columns along the bottom, bass in the middle, treble at the edges. */ +/** A background equalizer: soft columns along the bottom, the song's spectrum mirrored, bass in the middle. */ function horizon(): Painter { const N = 32 const level = new Float32Array(N) return (ctx, w, h, f, c) => { - const [b0, b1, b2, b3] = f.bands const t = f.now / 1000 const gap = w / N + const bands = f.spectrum.length const grad = ctx.createLinearGradient(0, h, 0, h * 0.35) grad.addColorStop(0, c.dominant) grad.addColorStop(0.6, c.vibrant) @@ -110,13 +110,12 @@ function horizon(): Painter { for (let i = 0; i < N; i++) { const x = (i + 0.5) / N const d = Math.abs(x - 0.5) * 2 // 0 middle, 1 edges - const shape = - b0 * (1 - d) ** 1.6 + - b1 * 0.7 * bump(d, 0.35, 0.25) + - b2 * 0.8 * bump(d, 0.62, 0.22) + - b3 * 0.6 * d ** 1.4 - // Each column wanders on its own, more when the song is loud. - const wander = f.energy * 0.22 * (0.5 + 0.5 * Math.sin(t * (1.3 + (i % 7) * 0.31) + i * 1.7)) + // Between the two nearest bands. + const b = d * (bands - 1) + const k = Math.min(Math.floor(b), bands - 2) + const shape = f.spectrum[k] * (1 - (b - k)) + f.spectrum[k + 1] * (b - k) + // Neighbouring columns share a band; a little wander tells them apart. + const wander = f.energy * 0.12 * (0.5 + 0.5 * Math.sin(t * (1.3 + (i % 7) * 0.31) + i * 1.7)) level[i] = follow(level[i], Math.min(1, shape + wander), f.dt, 50, 320) const bh = h * (0.04 + level[i] * 0.5) const bw = gap * 0.62 @@ -259,11 +258,6 @@ function embers(): Painter { const painters: Record Painter> = { mesh, horizon, ripples, aurora, embers } -function bump(x: number, at: number, width: number) { - const d = (x - at) / width - return Math.exp(-d * d) -} - function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath() ctx.roundRect(x, y, w, h, r) diff --git a/web/src/components/shell/beat-lab.tsx b/web/src/components/shell/beat-lab.tsx index aa92dbd..c14eb43 100644 --- a/web/src/components/shell/beat-lab.tsx +++ b/web/src/components/shell/beat-lab.tsx @@ -1,4 +1,5 @@ -import { Activity, X } from 'lucide-react' +import { Activity, ChevronDown } from 'lucide-react' +import { Popover } from 'radix-ui' import { useState } from 'react' import { Button } from '@/components/ui/button' import { Slider } from '@/components/ui/slider' @@ -7,129 +8,114 @@ import { useBeat } from '@/hooks/use-beat' import { SCENES, useScene } from '@/hooks/use-scene' import { beatSettings, - currentGrid, + beatSource, + currentBeat, gridVersion, nudge, resetGrid, scaleTempo, shiftDownbeat, tap, + type BeatOrigin, } from '@/lib/beat' -import { usePlayer } from '@/lib/now-playing' import { useStore } from '@/lib/store' import { cn } from '@/lib/utils' /** - * Dev-only tuning for the beat prototype (MAD-772): tap along to set the - * song's grid, fake the energy, or run without music. Goes away once the - * server sends beat maps. + * The beat lab (MAD-778): how the app moves with the music, on this + * device. Turn it off, make it calmer or stronger, pick the backdrop, and + * fix the beat by tapping along when the analysis got a song wrong. + * Opens from now playing; its button blinks on the beat. */ export function BeatLab() { const [open, setOpen] = useState(false) + const beat = useBeat() + const { level } = useStore(beatSettings) + + return ( + + + + + e.stopPropagation()} + className="z-[60] w-[min(20rem,calc(100vw-1.5rem))] outline-none" + > + + + + ) +} + +function Panel() { const settings = useStore(beatSettings) + const { map, mapKey, np } = useStore(beatSource) useStore(gridVersion) - const { nowPlaying } = usePlayer() - const [taps, setTaps] = useState(0) const beat = useBeat() - const grid = currentGrid() const scene = useScene() + const now = currentBeat() + const [fixing, setFixing] = useState(false) const set = (next: Partial) => beatSettings.set((s) => ({ ...s, ...next })) + const on = settings.level !== 'off' + const loading = !!np?.itemId && mapKey !== np.itemId && !map return ( -
- +
+
+

Beat lab

+ set({ level: v ? 'subtle' : 'off' })} /> +
- {open && ( -
-
-

Beat lab

+ {on && ( + <> +
+
+

+ {now.bpm > 0 ? `${Math.round(now.bpm)} BPM` : 'No steady beat'} +

+

{loading ? 'Listening to the song…' : origins[now.origin]}

+
- - set({ level: on ? 'subtle' : 'off' })} + - - set({ freeRun })} /> - - -
-
- - {nowPlaying && !nowPlaying.paused ? 'This song' : settings.freeRun ? 'Free run' : 'Nothing playing'} - - {Math.round(grid.bpm)} BPM -
- -

- Start on a “one” and tap at least 4 times. Without taps a song runs at the default tempo, - out of phase. -

-
- - - - - - - - -
-
+
- Backdrop {settings.scene === 'auto' ? `auto: ${scene}` : scene} + Backdrop {settings.scene === 'auto' ? `Auto: ${scene}` : scene}
{['auto', ...SCENES].map((s) => ( @@ -146,33 +132,86 @@ export function BeatLab() {
- -
+
+ + {fixing && } +
+ )}
) } -function Row({ label, children }: { label: string; children: React.ReactNode }) { +/** Tap along to set this song's beat, then nudge it. Only on this device. */ +function Fix({ bpm, tapped }: { bpm: number; tapped: boolean }) { + const [taps, setTaps] = useState(0) return ( -
- {label} - {children} +
+ +

+ Start on a “one” and tap at least 4 times. Fixes this song on this device. +

+
+ + + + + + + + +
) } +const origins: Record = { + analysed: 'From the song', + tapped: 'Tapped on this device', + steady: 'Moving with its loudness', + default: 'A guess: this song has no beat map', +} + +function intensityLabel(v: number) { + if (v < 0.8) return 'Calm' + if (v <= 1.2) return 'Normal' + if (v <= 1.5) return 'Strong' + return 'Max' +} + function Dot({ v, big }: { v: string; big?: boolean }) { return (
drag.start(e)} - className="flex touch-none items-center justify-between px-gutter pt-[calc(env(safe-area-inset-top)+0.75rem)] pb-3" + className="relative flex touch-none items-center justify-between px-gutter pt-[calc(env(safe-area-inset-top)+0.75rem)] pb-3" > - + {np.roomId ? ( - +
+ + +
) : ( )} diff --git a/web/src/components/tv/tv-stage.tsx b/web/src/components/tv/tv-stage.tsx index fc57bd0..7dd311c 100644 --- a/web/src/components/tv/tv-stage.tsx +++ b/web/src/components/tv/tv-stage.tsx @@ -9,6 +9,7 @@ import { CrownMoment } from '@/components/room/crown-moment' import { AlbumBackdrop } from '@/components/shell/album-backdrop' import { UserAvatar } from '@/components/user-avatar' import { useAlbumPalette } from '@/hooks/use-album-palette' +import { useBeatSync } from '@/hooks/use-beat-sync' import { usePosition } from '@/hooks/use-position' import { autopilotReason, autopilotSource } from '@/lib/autopilot' import { guestPassQuery } from '@/lib/guests' @@ -62,6 +63,7 @@ export function TvStage({ [roomId, playback.data, users.data], ) useAlbumPalette(np) + useBeatSync(np) useWakeLock() const { status } = useStore(live) diff --git a/web/src/hooks/use-beat-sync.ts b/web/src/hooks/use-beat-sync.ts new file mode 100644 index 0000000..77afce2 --- /dev/null +++ b/web/src/hooks/use-beat-sync.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react' +import { beatSource } from '@/lib/beat' +import { loadBeatMap } from '@/lib/beat-map' +import type { NowPlaying } from '@/lib/now-playing' + +/** + * Points the beat engine at what's playing, and loads the song's beat map. + * Mount it once where the now playing lives: the app shell, the big screen. + */ +export function useBeatSync(np: NowPlaying | null) { + useEffect(() => { + beatSource.set((s) => ({ ...s, np })) + }, [np]) + + const roomId = np?.roomId + const itemId = np?.itemId + useEffect(() => { + if (!roomId || !itemId) { + beatSource.set((s) => ({ ...s, map: null, mapKey: null })) + return + } + let stale = false + void loadBeatMap(roomId, itemId).then((map) => { + if (!stale) beatSource.set((s) => ({ ...s, map, mapKey: map ? itemId : null })) + }) + return () => { + stale = true + } + }, [roomId, itemId]) + + // Nothing playing here any more (the shell unmounted, say). + useEffect(() => () => beatSource.set({ np: null, map: null, mapKey: null }), []) +} diff --git a/web/src/hooks/use-scene.ts b/web/src/hooks/use-scene.ts index 478ecfe..7dfe1d7 100644 --- a/web/src/hooks/use-scene.ts +++ b/web/src/hooks/use-scene.ts @@ -1,22 +1,30 @@ -import { beatSettings } from '@/lib/beat' -import { usePlayer } from '@/lib/now-playing' +import { beatSettings, beatSource } from '@/lib/beat' import { useStore } from '@/lib/store' export const SCENES = ['mesh', 'horizon', 'ripples', 'aurora', 'embers'] as const export type Scene = (typeof SCENES)[number] /** - * The backdrop scene for the song playing: the setting's, or one picked by - * the song. The real pick will come from its genre, tempo and energy with - * the beat map; for now it's a stable hash of the queue item. + * The backdrop scene for the song playing: the setting's, or one that suits + * the song, from its beat map. A stable pick by the queue item breaks ties, + * so two songs alike don't always look the same. */ export function useScene(): Scene { const { scene } = useStore(beatSettings) - const { nowPlaying } = usePlayer() + const { np, map, mapKey } = useStore(beatSource) if (scene !== 'auto') return scene as Scene - const key = nowPlaying?.itemId ?? nowPlaying?.track.trackId - if (!key) return beatSettings.get().freeRun ? 'horizon' : 'mesh' + const key = np?.itemId ?? np?.track.trackId + if (!key) return 'mesh' let h = 0 for (const ch of key) h = (h * 31 + ch.charCodeAt(0)) | 0 - return SCENES[Math.abs(h) % SCENES.length] + const pick = (...from: Scene[]) => from[Math.abs(h) % from.length] + if (!map || mapKey !== np?.itemId) return pick(...SCENES) + const { energy, brightness } = map.features + // No steady beat (ambient, a drone, spoken word): something that flows. + if (map.bpm === 0) return pick('mesh', 'aurora') + // Quiet and slow: a ballad, acoustic. + if (energy < 0.4 || map.bpm < 90) return pick('mesh', 'aurora', 'embers') + // Loud with a driving beat: dance, rock. + if (energy > 0.65 && map.bpm >= 110) return brightness > 0.5 ? pick('horizon', 'ripples') : pick('horizon', 'embers') + return pick(...SCENES) } diff --git a/web/src/lib/beat-map.test.ts b/web/src/lib/beat-map.test.ts new file mode 100644 index 0000000..25da49d --- /dev/null +++ b/web/src/lib/beat-map.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { beatAt, decode, loudnessAt, sectionEnergy, spectrumAt } from './beat-map' + +const wire = { + durationMs: 10_000, + bpm: 120, + confidence: 0.8, + // A beat every 500 ms from 1 s; the first bar starts on the second beat. + beats: Array.from({ length: 16 }, (_, i) => 1000 + i * 500), + downbeat: 1, + sections: [ + { startMs: 0, energy: 0.2 }, + { startMs: 5000, energy: 0.9 }, + ], + frameRate: 2, + bandCount: 2, + // Two frames a second, two bands: [0, 255], [255, 0], ... + bands: btoa(String.fromCharCode(0, 255, 255, 0, 0, 255)), + loudness: btoa(String.fromCharCode(0, 128, 255)), + features: { energy: 0.5, brightness: 0.5, dynamics: 0.5 }, +} + +describe('beat maps', () => { + const m = decode(wire) + + it('decodes the envelopes', () => { + expect([...m.bands]).toEqual([0, 255, 255, 0, 0, 255]) + expect([...m.loudness]).toEqual([0, 128, 255]) + }) + + it('finds the beat and its place in the bar', () => { + expect(beatAt(m, 999)).toBeNull() + expect(beatAt(m, 1000)).toEqual({ index: 0, into: 0, period: 500, bar: 3 }) + expect(beatAt(m, 1600)).toEqual({ index: 1, into: 100, period: 500, bar: 0 }) + expect(beatAt(m, 3500)?.bar).toBe(0) + // The last beat lasts as long as the one before it. + expect(beatAt(m, 8700)).toEqual({ index: 15, into: 200, period: 500, bar: 2 }) + expect(beatAt(m, 9000)).toBeNull() + }) + + it('reads sections, loudness and the spectrum', () => { + expect(sectionEnergy(m, 4999)).toBe(0.2) + expect(sectionEnergy(m, 5000)).toBe(0.9) + expect(loudnessAt(m, 600)).toBeCloseTo(128 / 255) + expect(loudnessAt(m, 99_000)).toBe(0) + const out = new Float32Array(2) + // Halfway between the first two frames. + expect([...spectrumAt(m, 250, out)]).toEqual([0.5, 0.5]) + expect([...spectrumAt(m, -10, out)]).toEqual([0, 0]) + }) +}) diff --git a/web/src/lib/beat-map.ts b/web/src/lib/beat-map.ts new file mode 100644 index 0000000..d1293e3 --- /dev/null +++ b/web/src/lib/beat-map.ts @@ -0,0 +1,106 @@ +import { api } from '@/api/client' +import { unwrap } from '@/api/errors' +import type { components } from '@/api/schema.gen' + +// Beat maps (MAD-773): the server works out each song's beats, bars, +// sections and spectrum once, from its audio. This loads them and answers +// "where in the music are we?" for the beat engine (lib/beat.ts). + +type Wire = components['schemas']['BeatMap'] + +export type BeatMap = Omit & { + bands: Uint8Array + loudness: Uint8Array +} + +const loaded = new Map>() + +/** A queued song's beat map, or null if it can't have one. Cached per item. */ +export function loadBeatMap(roomId: string, itemId: string): Promise { + const key = `${roomId}/${itemId}` + let hit = loaded.get(key) + if (!hit) { + hit = unwrap(api.GET('/rooms/{roomId}/queue/{itemId}/beatmap', { params: { path: { roomId, itemId } } })) + .then(decode) + .catch(() => { + // A miss now (no map, or the server was busy) may be a hit later. + setTimeout(() => loaded.delete(key), 60_000) + return null + }) + loaded.set(key, hit) + // Keep the last few songs' maps; they're up to a few hundred KB. + while (loaded.size > 8) loaded.delete(loaded.keys().next().value!) + } + return hit +} + +export function decode(w: Wire): BeatMap { + return { ...w, bands: base64(w.bands), loudness: base64(w.loudness) } +} + +function base64(s: string) { + const bin = atob(s) + const out = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i) + return out +} + +/** Where pos (ms) falls among the beats. */ +export type BeatPlace = { + /** Beats since the first, and ms into this one. */ + index: number + into: number + /** This beat's length, ms. */ + period: number + /** Its place in the bar, 0 for the one. */ + bar: number +} + +/** The beat at pos, or null before the first beat and after the last. */ +export function beatAt(m: BeatMap, pos: number): BeatPlace | null { + const b = m.beats + if (b.length < 2 || pos < b[0] || pos >= b[b.length - 1] + (b[b.length - 1] - b[b.length - 2])) return null + // The last beat at or before pos. + let lo = 0 + let hi = b.length - 1 + while (lo < hi) { + const mid = (lo + hi + 1) >> 1 + if (b[mid] <= pos) lo = mid + else hi = mid - 1 + } + const period = lo + 1 < b.length ? b[lo + 1] - b[lo] : b[lo] - b[lo - 1] + return { index: lo, into: pos - b[lo], period, bar: (((lo - m.downbeat) % 4) + 4) % 4 } +} + +/** The section energy at pos, 0–1. */ +export function sectionEnergy(m: BeatMap, pos: number) { + let e = m.sections[0]?.energy ?? 0.5 + for (const s of m.sections) { + if (s.startMs > pos) break + e = s.energy + } + return e +} + +/** Each band's level at pos, 0–1, into out (bandCount long). */ +export function spectrumAt(m: BeatMap, pos: number, out: Float32Array) { + const f = (pos / 1000) * m.frameRate + const i = Math.floor(f) + const frames = m.loudness.length + if (i < 0 || i >= frames) { + out.fill(0) + return out + } + const j = Math.min(i + 1, frames - 1) + const t = f - i + for (let k = 0; k < m.bandCount; k++) { + out[k] = (m.bands[i * m.bandCount + k] * (1 - t) + m.bands[j * m.bandCount + k] * t) / 255 + } + return out +} + +/** The overall level at pos, 0–1. */ +export function loudnessAt(m: BeatMap, pos: number) { + const i = Math.floor((pos / 1000) * m.frameRate) + return i >= 0 && i < m.loudness.length ? m.loudness[i] / 255 : 0 +} diff --git a/web/src/lib/beat.ts b/web/src/lib/beat.ts index b890da3..4639395 100644 --- a/web/src/lib/beat.ts +++ b/web/src/lib/beat.ts @@ -1,10 +1,14 @@ -// The beat engine (MAD-775, prototype). One rAF loop turns the room's -// playback position into a few CSS custom properties, so the app can -// breathe with the song without re-rendering anything. +// The beat engine (MAD-775). One rAF loop turns the room's playback +// position into a few CSS custom properties, so the app can breathe with +// the song without re-rendering anything. // -// The real thing reads a server-side beat map (MAD-773). Until then a song's -// beat grid comes from tapping along (see BeatLab); a song nobody tapped -// runs on the default tempo, so it's in time but not in phase. +// Where the beat comes from, first match wins: +// 1. a grid tapped in the beat lab, for a song the analysis got wrong; +// 2. the song's beat map from the server (lib/beat-map.ts); +// 3. the default tempo, while the map loads or for songs without one: +// in time, but not in phase. +// The map also gives the energy (its sections and loudness) and the +// spectrum. // // Variables, written only to the elements that use them (a var on :root // would restyle the whole tree every frame): @@ -13,62 +17,105 @@ // --swell a slow breath once a bar: rises over a beat, fades over the // rest. For big surfaces, where a per-beat flash reads as strobe. // --energy how hard the song is going right now, slowly smoothed. -// --b0..--b3 four synthetic "bands" for the equalizer. +// --b0..--b3 bass, low mids, high mids and treble, for the equalizer. // Elements also get data-beat-live while the engine drives them, so CSS // can swap a canned animation for the live one. -import { player, positionAt } from './now-playing' +import { beatAt, loudnessAt, sectionEnergy, spectrumAt, type BeatMap } from './beat-map' +import { positionAt, type NowPlaying } from './now-playing' import { createStore } from './store' export type BeatLevel = 'off' | 'subtle' export type BeatSettings = { level: BeatLevel - /** Tempo for songs with no grid of their own. */ - defaultBpm: number - /** Stand-in for the beat map's energy envelope, 0–1. */ - energy: number - /** Run on a free clock when nothing's playing, to try the look. */ - freeRun: boolean + /** How strongly things move, 0.5 (calmer) – 1.75 (stronger). */ + intensity: number /** The backdrop scene, or 'auto' to pick one per song. */ scene: string } -export const beatSettings = createStore({ - level: 'subtle', - defaultBpm: 120, - energy: 0.5, - freeRun: false, - scene: 'auto', +const SETTINGS_KEY = 'syncphony-visuals' +const DEFAULTS: BeatSettings = { level: 'subtle', intensity: 1, scene: 'auto' } + +export const beatSettings = createStore({ ...DEFAULTS, ...readSettings() }) +beatSettings.subscribe(() => { + const { level, intensity, scene } = beatSettings.get() + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ level, intensity, scene })) + } catch { + // Private mode: settings last until the tab closes. + } +}) + +function readSettings(): Partial { + try { + const s = JSON.parse(localStorage.getItem(SETTINGS_KEY) ?? '{}') as Partial + return { + ...(s.level === 'off' || s.level === 'subtle' ? { level: s.level } : {}), + ...(typeof s.intensity === 'number' ? { intensity: Math.min(1.75, Math.max(0.5, s.intensity)) } : {}), + ...(typeof s.scene === 'string' ? { scene: s.scene } : {}), + } + } catch { + return {} + } +} + +/** Tempo for songs with no beat map yet. */ +const DEFAULT_BPM = 120 + +/** + * What's playing, and its beat map once loaded. Set by useBeatSync, which + * the app shell and the big screen each mount once. + */ +export const beatSource = createStore<{ np: NowPlaying | null; map: BeatMap | null; mapKey: string | null }>({ + np: null, + map: null, + mapKey: null, }) /** A song's beat grid: its tempo and the position of one downbeat, in ms. */ export type Grid = { bpm: number; anchorMs: number } -const grids = new Map() +/** Grids tapped in the beat lab, by song. They beat the song's map. */ +const tapped = new Map() export const gridVersion = createStore(0) -const FREE_KEY = '__free' -const freeStart = performance.now() +function keyOf(np: NowPlaying) { + return np.itemId ?? np.track.trackId +} /** Where the music is now, and which song's grid applies. */ function clock(): { key: string; pos: number; playing: boolean } | null { - const np = player.get().nowPlaying - if (np && !np.paused) return { key: np.itemId ?? np.track.trackId, pos: positionAt(np, Date.now()), playing: true } - if (beatSettings.get().freeRun) return { key: FREE_KEY, pos: performance.now() - freeStart, playing: true } - if (np) return { key: np.itemId ?? np.track.trackId, pos: np.positionMs, playing: false } + const { np } = beatSource.get() + if (np && !np.paused) return { key: keyOf(np), pos: positionAt(np, Date.now()), playing: true } + if (np) return { key: keyOf(np), pos: np.positionMs, playing: false } return null } -export function currentGrid(): Grid { +/** The current song's map, if it's loaded. */ +function mapFor(key: string) { + const { map, mapKey } = beatSource.get() + return map && mapKey === key ? map : null +} + +/** Where the current song's beat comes from, for the beat lab. */ +export type BeatOrigin = 'tapped' | 'analysed' | 'steady' | 'default' + +export function currentBeat(): Grid & { origin: BeatOrigin } { const c = clock() - return (c && grids.get(c.key)) ?? { bpm: beatSettings.get().defaultBpm, anchorMs: 0 } + const own = c && tapped.get(c.key) + if (own) return { ...own, origin: 'tapped' } + const map = c && mapFor(c.key) + if (map && map.bpm > 0) return { bpm: map.bpm, anchorMs: map.beats[map.downbeat] ?? 0, origin: 'analysed' } + if (map) return { bpm: 0, anchorMs: 0, origin: 'steady' } + return { bpm: DEFAULT_BPM, anchorMs: 0, origin: 'default' } } function setGrid(g: Grid) { const c = clock() if (!c) return - grids.set(c.key, { bpm: Math.min(220, Math.max(50, g.bpm)), anchorMs: g.anchorMs }) + tapped.set(c.key, { bpm: Math.min(220, Math.max(50, g.bpm)), anchorMs: g.anchorMs }) gridVersion.set((v) => v + 1) wake() } @@ -107,26 +154,34 @@ export function tap() { return n } +/** The current beat as a grid to adjust: tapped, analysed, or the default. */ +function adjustable(): Grid { + const b = currentBeat() + return b.bpm > 0 ? b : { bpm: DEFAULT_BPM, anchorMs: 0 } +} + export function nudge(ms: number) { - const g = currentGrid() + const g = adjustable() setGrid({ ...g, anchorMs: g.anchorMs + ms }) } export function scaleTempo(factor: number) { - const g = currentGrid() + const g = adjustable() setGrid({ ...g, bpm: g.bpm * factor }) } -/** Moves the "one" along by a beat, for a grid tapped from the wrong beat. */ +/** Moves the "one" along by a beat, for a grid that starts its bars in the wrong place. */ export function shiftDownbeat() { - const g = currentGrid() + const g = adjustable() setGrid({ ...g, anchorMs: g.anchorMs + 60000 / g.bpm }) } +/** Forgets the song's tapped grid, back to its beat map. */ export function resetGrid() { const c = clock() if (!c) return - grids.delete(c.key) + tapped.delete(c.key) + taps = [] gridVersion.set((v) => v + 1) } @@ -156,12 +211,16 @@ const reduced = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced- // Smoothed state, eased every frame so nothing snaps. let presence = 0 let energy = 0 +let loud = 0 let swell = 0 let frame = 0 let lastFrame = 0 let lastRate = 1 let lastRateAt = 0 const written = new WeakMap>() +const BANDS = 12 +const spectrum = new Float32Array(BANDS) +const level = new Float32Array(BANDS) /** One frame of the engine, for canvas scenes that draw rather than style. */ export type BeatFrame = { @@ -169,8 +228,10 @@ export type BeatFrame = { downbeat: number swell: number energy: number - /** Four bands, low to high (synthetic until the beat map has real ones). */ + /** Four bands, low to high. */ bands: [number, number, number, number] + /** Twelve bands, low to high, 0–1 (from the beat map, else made up from the beat). */ + spectrum: Float32Array /** Fades in when music starts, out when it stops. */ presence: number /** Beats since the grid's anchor, and the beat's length in ms. */ @@ -206,6 +267,18 @@ function wake() { frame = requestAnimationFrame(tick) } +/** Where pos falls in the song's beat, from whichever source applies. */ +function place(key: string, pos: number): { index: number; into: number; period: number; bar: number } | null { + const own = tapped.get(key) + const map = mapFor(key) + if (!own && map) return map.bpm > 0 ? beatAt(map, pos) : null + const { bpm, anchorMs } = own ?? { bpm: DEFAULT_BPM, anchorMs: 0 } + const period = 60000 / bpm + const since = pos - anchorMs + const n = Math.floor(since / period) + return { index: n, into: since - n * period, period, bar: ((n % 4) + 4) % 4 } +} + function tick(now: number) { frame = 0 const dt = Math.min(100, now - lastFrame) @@ -217,40 +290,64 @@ function tick(now: number) { const c = clock() const playing = !!c?.playing && document.visibilityState === 'visible' + const map = c ? mapFor(c.key) : null + const intensity = beatSettings.get().intensity // Fades in over about a second when the music starts, out when it stops. presence = approach(presence, playing ? 1 : 0, dt, playing ? 900 : 500) - energy = approach(energy, beatSettings.get().energy, dt, 1500) + + // Energy: the section's, nudged by how loud it is right now. + let target = 0.5 + if (map && c) { + loud = approach(loud, loudnessAt(map, c.pos), dt, 600) + target = 0.55 * sectionEnergy(map, c.pos) + 0.45 * loud + } + energy = approach(energy, target, dt, 1500) const vars = { beat: 0, downbeat: 0, swell: 0, energy: energy * presence, b0: 0, b1: 0, b2: 0, b3: 0 } let beatIndex = 0 let periodMs = 500 let breath = 0 - if (c && presence > 0.001) { - const { bpm, anchorMs } = grids.get(c.key) ?? { bpm: beatSettings.get().defaultBpm, anchorMs: 0 } - const period = 60000 / bpm - const since = c.pos - anchorMs - const n = Math.floor(since / period) - const into = since - n * period - const bar = ((n % 4) + 4) % 4 - beatIndex = n - periodMs = period + const p = c && presence > 0.001 ? place(c.key, c.pos) : null + // Louder songs hit harder, but even a quiet one keeps a faint pulse. + const gain = presence * (0.35 + 0.65 * energy) * intensity + if (p) { + beatIndex = p.index + periodMs = p.period + vars.beat = gain * pulse(p.into, Math.min(240, p.period * 0.4)) + vars.downbeat = p.bar === 0 ? gain * pulse(p.into, Math.min(520, p.period * 0.85)) : 0 // Rises over the first beat of the bar and sinks over the rest. - const intoBar = bar * period + into - breath = intoBar < period ? Math.sin(((intoBar / period) * Math.PI) / 2) : Math.exp(-(intoBar - period) / (period * 1.2)) - // Louder songs hit harder, but even a quiet one keeps a faint pulse. - const gain = presence * (0.35 + 0.65 * energy) - vars.beat = gain * pulse(into, Math.min(240, period * 0.4)) - vars.downbeat = bar === 0 ? gain * pulse(into, Math.min(520, period * 0.85)) : 0 - // Synthetic bands: kick on the beat, snare on 2 and 4, offbeat eighths, sixteenths. - const half = period / 2 - const quarter = period / 4 - vars.b0 = gain * pulse(into, period * 0.5) - vars.b1 = bar % 2 === 1 ? gain * pulse(into, period * 0.6) : gain * 0.15 - vars.b2 = gain * pulse((into + half) % period, half * 0.7) * 0.8 - vars.b3 = gain * (0.35 + 0.65 * pulse(into % quarter, quarter * 0.6)) * (0.4 + 0.6 * energy) + const intoBar = p.bar * p.period + p.into + breath = + intoBar < p.period ? Math.sin(((intoBar / p.period) * Math.PI) / 2) : Math.exp(-(intoBar - p.period) / (p.period * 1.2)) } - swell = approach(swell, breath * presence * (0.45 + 0.55 * energy), dt, 160) + if (map && c && presence > 0.001) { + spectrumAt(map, c.pos, spectrum) + } else if (p) { + // No map: make a spectrum up from the beat, kick low and hats high. + const kick = pulse(p.into, p.period * 0.5) + const snare = p.bar % 2 === 1 ? pulse(p.into, p.period * 0.6) : 0.15 + const hats = 0.35 + 0.65 * pulse(p.into % (p.period / 4), p.period * 0.15) + for (let k = 0; k < BANDS; k++) { + const x = k / (BANDS - 1) + spectrum[k] = kick * Math.max(0, 1 - x * 3) + snare * Math.exp(-(((x - 0.45) / 0.2) ** 2)) + hats * x ** 2 * 0.7 + } + } else { + spectrum.fill(0) + } + // Each band's level: up fast, down slower, and only the top of its range, + // so the bars move rather than sit high. + for (let k = 0; k < BANDS; k++) { + const v = Math.max(0, (spectrum[k] - 0.4) / 0.6) ** 1.3 + level[k] = follow(level[k], v * presence, dt, 40, 220) + } + const group = (from: number) => ((level[from] + level[from + 1] + level[from + 2]) / 3) * Math.min(1.5, intensity) + vars.b0 = group(0) + vars.b1 = group(3) + vars.b2 = group(6) + vars.b3 = group(9) + + swell = approach(swell, breath * presence * (0.45 + 0.55 * energy) * intensity, dt, 160) vars.swell = swell // Calm songs drift slowly, loud ones churn. Only now and then: changing @@ -270,6 +367,7 @@ function tick(now: number) { swell: vars.swell, energy: vars.energy, bands: [vars.b0, vars.b1, vars.b2, vars.b3], + spectrum: level, presence, beatIndex, periodMs, @@ -286,7 +384,7 @@ function tick(now: number) { } // Idle once everything has settled: nothing playing, nothing fading. - if (playing || presence > 0.001 || swell > 0.001 || Math.abs(energy - beatSettings.get().energy) > 0.001) frame = requestAnimationFrame(tick) + if (playing || presence > 0.001 || swell > 0.001 || Math.abs(energy - target) > 0.001) frame = requestAnimationFrame(tick) else { for (const t of targets) if (t.drift) for (const a of t.el.getAnimations({ subtree: true })) a.updatePlaybackRate(1) lastRate = 1 @@ -305,6 +403,11 @@ function approach(from: number, to: number, dt: number, ms: number) { return Math.abs(next - to) < 0.0005 ? to : next } +/** Eases a value: fast up (attack), slower down (release). */ +function follow(from: number, to: number, dt: number, attack: number, release: number) { + return from + (to - from) * (1 - Math.exp(-dt / ((to > from ? attack : release) / 3))) +} + function write(el: HTMLElement, vars: Record) { const prev = written.get(el) ?? {} for (const [k, v] of Object.entries(vars)) { @@ -322,7 +425,7 @@ function clear(t: Target) { if (t.drift) for (const a of t.el.getAnimations({ subtree: true })) a.updatePlaybackRate(1) } -player.subscribe(wake) +beatSource.subscribe(wake) beatSettings.subscribe(wake) reduced?.addEventListener('change', wake) if (typeof document !== 'undefined') document.addEventListener('visibilitychange', wake) From 30c8f3200c3a25d128bc155f170431bda4908e01 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 21:31:38 -0500 Subject: [PATCH 04/15] Beat lab becomes a visual editor; lyrics glow, waveform, flash cap (MAD-776..778, MAD-782) The beat lab is back as a floating button on every page (in now playing's header while that's open) and now edits everything about the visuals on this device: an overall switch and intensity, the backdrop scene, each effect's strength or off (backdrop breathing, scene, drift, album art, glass shine, equalizer, lyrics glow, waveform), this device's sound delay (set by hand or measured by tapping along against the beat map), and fixing a song's beat by tapping. Reset everything restores the defaults. The lyric being sung glows on the beat; the seek bar shows the song's waveform from its beat map; flashes never exceed three a second; the app's backdrop scene stops drawing while now playing covers it. Co-Authored-By: Claude Opus 5.5 --- web/src/components/lyrics/lyrics-view.tsx | 16 +- web/src/components/shell/album-backdrop.tsx | 9 +- web/src/components/shell/app-shell.tsx | 5 +- web/src/components/shell/backdrop-scene.tsx | 6 +- web/src/components/shell/beat-lab.tsx | 359 +++++++++++------- .../components/shell/now-playing-sheet.tsx | 11 +- web/src/components/shell/waveform.tsx | 58 +++ web/src/index.css | 28 +- web/src/lib/beat.ts | 106 +++++- 9 files changed, 429 insertions(+), 169 deletions(-) create mode 100644 web/src/components/shell/waveform.tsx diff --git a/web/src/components/lyrics/lyrics-view.tsx b/web/src/components/lyrics/lyrics-view.tsx index 7f8a75b..8cf089c 100644 --- a/web/src/components/lyrics/lyrics-view.tsx +++ b/web/src/components/lyrics/lyrics-view.tsx @@ -1,8 +1,9 @@ import { useQuery } from '@tanstack/react-query' import { Mic2, Minus, Music4, Plus, RotateCcw } from 'lucide-react' import { motion, useReducedMotion } from 'motion/react' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { Skeleton } from '@/components/ui/skeleton' +import { useBeat } from '@/hooks/use-beat' import { easeOutExpo } from '@/lib/motion' import { activeLine, @@ -92,6 +93,15 @@ function SyncedLyrics({ const position = () => positionAt(np, Math.max(Date.now(), np.at)) - offset const current = useActiveLine(lines, np, position) const scroller = useRef(null) + // The current line's glow follows the beat. + const beat = useBeat() + const scrollerRef = useCallback( + (el: HTMLDivElement | null) => { + scroller.current = el + return beat(el) + }, + [beat], + ) const lineRefs = useRef<(HTMLElement | null)[]>([]) const reduced = useReducedMotion() // Reading ahead pauses following along for a few seconds. @@ -109,7 +119,7 @@ function SyncedLyrics({ return (
{line.text} diff --git a/web/src/components/shell/album-backdrop.tsx b/web/src/components/shell/album-backdrop.tsx index d7f3547..f2a9e99 100644 --- a/web/src/components/shell/album-backdrop.tsx +++ b/web/src/components/shell/album-backdrop.tsx @@ -15,7 +15,12 @@ import { BackdropScene } from './backdrop-scene' * mesh drifts faster when the song is loud, and each song gets a scene * drawn over it (see backdrop-scene.tsx). */ -export function AlbumBackdrop({ src: next, className }: { src?: string; className?: string }) { +export function AlbumBackdrop({ src: next, scene = true, className }: { + src?: string + /** Draw the song's scene; off while something covers the backdrop. */ + scene?: boolean + className?: string +}) { // Crossfade to art that's ready, not to an empty image. const src = useLoadedSrc(next) const beat = useBeat({ drift: true }) @@ -43,7 +48,7 @@ export function AlbumBackdrop({ src: next, className }: { src?: string; classNam )}
- + {scene && }
) } diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx index 095f074..675639d 100644 --- a/web/src/components/shell/app-shell.tsx +++ b/web/src/components/shell/app-shell.tsx @@ -9,6 +9,7 @@ import { usePlayer } from '@/lib/now-playing' import { useOnline } from '@/lib/pwa' import { Toaster } from '@/components/toaster' import { AlbumBackdrop } from './album-backdrop' +import { BeatLab } from './beat-lab' import { BottomNav } from './bottom-nav' import { MiniPlayer } from './mini-player' import { NowPlayingSheet } from './now-playing-sheet' @@ -27,7 +28,8 @@ export function AppShell() { return (
- + {/* Its scene rests while now playing covers it. */} +
@@ -43,6 +45,7 @@ export function AppShell() {
+ {!expanded && }
) diff --git a/web/src/components/shell/backdrop-scene.tsx b/web/src/components/shell/backdrop-scene.tsx index 33befbc..2523f68 100644 --- a/web/src/components/shell/backdrop-scene.tsx +++ b/web/src/components/shell/backdrop-scene.tsx @@ -1,7 +1,7 @@ import { AnimatePresence, motion } from 'motion/react' import { useEffect, useRef } from 'react' import { useScene, type Scene } from '@/hooks/use-scene' -import { onBeatFrame, type BeatFrame } from '@/lib/beat' +import { beatSettings, onBeatFrame, type BeatFrame } from '@/lib/beat' /* * Backdrop scenes (MAD-772 prototype): a canvas over the palette mesh that @@ -65,8 +65,10 @@ function SceneCanvas({ paint }: { paint: () => Painter }) { } ctx.clearRect(0, 0, canvas.width, canvas.height) if (f.presence < 0.002) return + const strength = beatSettings.get().effects.scene + if (strength === 0) return ctx.save() - ctx.globalAlpha = f.presence + ctx.globalAlpha = Math.min(1, f.presence * strength) draw(ctx, canvas.width, canvas.height, f, colors) ctx.restore() }) diff --git a/web/src/components/shell/beat-lab.tsx b/web/src/components/shell/beat-lab.tsx index c14eb43..93209a0 100644 --- a/web/src/components/shell/beat-lab.tsx +++ b/web/src/components/shell/beat-lab.tsx @@ -1,33 +1,43 @@ -import { Activity, ChevronDown } from 'lucide-react' +import { Activity, RotateCcw, X } from 'lucide-react' import { Popover } from 'radix-ui' -import { useState } from 'react' +import { useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Slider } from '@/components/ui/slider' import { Switch } from '@/components/ui/switch' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' import { useBeat } from '@/hooks/use-beat' import { SCENES, useScene } from '@/hooks/use-scene' import { + BEAT_DEFAULTS, + EFFECTS, beatSettings, beatSource, + calibrateTap, currentBeat, gridVersion, nudge, resetGrid, scaleTempo, + setBeatSettings, shiftDownbeat, tap, type BeatOrigin, + type Effect, } from '@/lib/beat' import { useStore } from '@/lib/store' import { cn } from '@/lib/utils' +type Tab = 'look' | 'effects' | 'beat' + /** - * The beat lab (MAD-778): how the app moves with the music, on this - * device. Turn it off, make it calmer or stronger, pick the backdrop, and - * fix the beat by tapping along when the analysis got a song wrong. - * Opens from now playing; its button blinks on the beat. + * The beat lab (MAD-778): everything about how the app moves with the + * music, on this device. Turn it all off, set the overall intensity, pick + * the backdrop, tune or switch off each effect, tell it how late this + * device's sound is, and fix a song's beat by tapping along. Always a tap + * away: floating top right, or `inline` in a header (now playing's). Its + * button blinks on the beat. */ -export function BeatLab() { +export function BeatLab({ inline = false }: { inline?: boolean }) { const [open, setOpen] = useState(false) const beat = useBeat() const { level } = useStore(beatSettings) @@ -35,114 +45,81 @@ export function BeatLab() { return ( - + {open ? ( + + ) : ( + + + {/* Blinks on the beat: is it in time? */} + + + )} + - e.stopPropagation()} - className="z-[60] w-[min(20rem,calc(100vw-1.5rem))] outline-none" - > - - + + + + + ) } function Panel() { const settings = useStore(beatSettings) - const { map, mapKey, np } = useStore(beatSource) - useStore(gridVersion) + const [tab, setTab] = useState('look') const beat = useBeat() - const scene = useScene() - const now = currentBeat() - const [fixing, setFixing] = useState(false) - const set = (next: Partial) => beatSettings.set((s) => ({ ...s, ...next })) const on = settings.level !== 'off' - const loading = !!np?.itemId && mapKey !== np.itemId && !map return ( -
-
+
+

Beat lab

- set({ level: v ? 'subtle' : 'off' })} /> + setBeatSettings({ level: v ? 'subtle' : 'off' })} />
- - {on && ( + {!on ? ( +

Everything holds still. Switch it back on to move with the music.

+ ) : ( <> -
-
-

- {now.bpm > 0 ? `${Math.round(now.bpm)} BPM` : 'No steady beat'} -

-

{loading ? 'Listening to the song…' : origins[now.origin]}

-
-
- - -
+ v && setTab(v as Tab)} + className="mx-4 mt-3 grid w-auto grid-cols-3" + > + Look + Effects + Beat + +
+ {tab === 'look' && } + {tab === 'effects' && } + {tab === 'beat' && }
- - - -
- - Backdrop {settings.scene === 'auto' ? `Auto: ${scene}` : scene} - -
- {['auto', ...SCENES].map((s) => ( - - ))} -
-
- -
- - {fixing && } +
+
)} @@ -150,50 +127,158 @@ function Panel() { ) } -/** Tap along to set this song's beat, then nudge it. Only on this device. */ -function Fix({ bpm, tapped }: { bpm: number; tapped: boolean }) { +function Look() { + const { intensity, scene: setting } = useStore(beatSettings) + const scene = useScene() + return ( + <> + + setBeatSettings({ intensity: v })} /> + + {setting === 'auto' ? `Auto: ${scene}` : scene}}> +
+ {['auto', ...SCENES].map((s) => ( + + ))} +
+

Auto picks a backdrop that suits each song.

+
+ + ) +} + +function Effects() { + const { effects } = useStore(beatSettings) + return ( + <> + {(Object.keys(EFFECTS) as Effect[]).map((k) => ( + +
+ setBeatSettings({ effects: { [k]: v } })} + className="flex-1" + /> + 0} + onChange={(v) => setBeatSettings({ effects: { [k]: v ? 1 : 0 } })} + /> +
+
+ ))} + + ) +} + +function Beat() { + const { delayMs } = useStore(beatSettings) + const { map, mapKey, np } = useStore(beatSource) + useStore(gridVersion) + const now = currentBeat() + const loading = !!np?.itemId && mapKey !== np.itemId && !map + const [heard, setHeard] = useState(0) const [taps, setTaps] = useState(0) + const canCalibrate = now.origin === 'analysed' && !!np && !np.paused + return ( -
- -

- Start on a “one” and tap at least 4 times. Fixes this song on this device. -

-
- - - - - - - - -
+

+ Start on a “one” and tap at least 4 times. Fixes this song on this device. +

+
+ + + + + + + + +
+ + + ) +} + +function Field({ label, value, children }: { label: string; value?: ReactNode; children: ReactNode }) { + return ( +
+ + {label} + {value !== undefined && {value}} + + {children}
) } diff --git a/web/src/components/shell/now-playing-sheet.tsx b/web/src/components/shell/now-playing-sheet.tsx index cf43ede..f2c5b96 100644 --- a/web/src/components/shell/now-playing-sheet.tsx +++ b/web/src/components/shell/now-playing-sheet.tsx @@ -17,9 +17,10 @@ import { laneStyle } from '@/lib/lane' import { easeOutExpo, spring } from '@/lib/motion' import { formatDuration, usePlayer } from '@/lib/now-playing' import { AlbumBackdrop } from './album-backdrop' -import { BeatLab } from './beat-lab' import { TransportControls } from './player-controls' +import { BeatLab } from './beat-lab' import { SheetQueue } from './sheet-queue' +import { Waveform } from './waveform' /** * Full-screen now playing, with the room's queue below (beside, on wide @@ -80,7 +81,7 @@ export function NowPlayingSheet({ open, onOpenChange }: { open: boolean; onOpenC {np.roomId ? (
- + + + {(displays.data?.length ?? 0) > 0 && (

Paired screens

diff --git a/web/src/components/room/big-screen-look.tsx b/web/src/components/room/big-screen-look.tsx new file mode 100644 index 0000000..b250613 --- /dev/null +++ b/web/src/components/room/big-screen-look.tsx @@ -0,0 +1,98 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { api } from '@/api/client' +import { errorMessage, unwrap } from '@/api/errors' +import { Button } from '@/components/ui/button' +import { Slider } from '@/components/ui/slider' +import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' +import { SHOWS } from '@/hooks/use-scene' +import { roomsQuery, type Room } from '@/lib/room' +import { toast } from '@/lib/toast' + +type Screens = Room['screens'] + +const looks: { value: Screens['look']; label: string; hint: string }[] = [ + { value: 'auto', label: 'Auto', hint: 'Lyrics and what’s next, with the visualizer for songs with no words.' }, + { value: 'stage', label: 'Stage', hint: 'Now playing, lyrics and what’s next.' }, + { value: 'visualizer', label: 'Visualizer', hint: 'Full-screen visuals, with what’s playing in a corner.' }, +] + +/** + * How the room's big screens show the music (MAD-779). Every screen + * follows; whoever manages the room changes it. + */ +export function BigScreenLook({ room, editable }: { room: Room; editable: boolean }) { + const queryClient = useQueryClient() + const [intensity, setIntensity] = useState() + const update = useMutation({ + mutationFn: (screens: Screens) => + unwrap(api.PATCH('/rooms/{roomId}', { params: { path: { roomId: room.id } }, body: { screens } })), + onMutate: (screens) => { + const before = queryClient.getQueryData(roomsQuery.queryKey) + queryClient.setQueryData(roomsQuery.queryKey, (rs) => rs?.map((r) => (r.id === room.id ? { ...r, screens } : r))) + return before + }, + onError: (e, _, before) => { + queryClient.setQueryData(roomsQuery.queryKey, before) + toast({ message: errorMessage(e), tone: 'error' }) + }, + onSettled: () => setIntensity(undefined), + }) + const s = room.screens + const set = (next: Partial) => update.mutate({ ...s, ...next }) + const shown = intensity ?? s.intensity + + return ( +
+

How screens look

+ v && set({ look: v as Screens['look'] })} + className="grid w-full grid-cols-3" + > + {looks.map((l) => ( + + {l.label} + + ))} + +

{looks.find((l) => l.value === s.look)?.hint}

+ +
+ Visualizer +
+ {['', ...SHOWS].map((show) => ( + + ))} +
+
+ + + {!editable &&

Whoever runs the room can change these.

} +
+ ) +} diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx index 675639d..7e0f169 100644 --- a/web/src/components/shell/app-shell.tsx +++ b/web/src/components/shell/app-shell.tsx @@ -8,6 +8,7 @@ import { easeOutExpo } from '@/lib/motion' import { usePlayer } from '@/lib/now-playing' import { useOnline } from '@/lib/pwa' import { Toaster } from '@/components/toaster' +import { VisualizerMode } from '@/components/visualizer/visualizer-mode' import { AlbumBackdrop } from './album-backdrop' import { BeatLab } from './beat-lab' import { BottomNav } from './bottom-nav' @@ -46,6 +47,7 @@ export function AppShell() { {!expanded && } +
) diff --git a/web/src/components/shell/backdrop-scene.tsx b/web/src/components/shell/backdrop-scene.tsx index 2523f68..aefd210 100644 --- a/web/src/components/shell/backdrop-scene.tsx +++ b/web/src/components/shell/backdrop-scene.tsx @@ -1,23 +1,19 @@ import { AnimatePresence, motion } from 'motion/react' import { useEffect, useRef } from 'react' -import { useScene, type Scene } from '@/hooks/use-scene' -import { beatSettings, onBeatFrame, type BeatFrame } from '@/lib/beat' +import { useScene } from '@/hooks/use-scene' +import { beatSettings, onBeatFrame } from '@/lib/beat' +import { painters, readColors, type Painter } from '@/lib/scenes' +import { cn } from '@/lib/utils' /* - * Backdrop scenes (MAD-772 prototype): a canvas over the palette mesh that - * draws something music-shaped in the art's colors. Each song gets one; the - * pick is in use-scene.ts. + * Backdrop scenes (MAD-782): a canvas over the palette mesh that draws + * something music-shaped in the art's colors (lib/scenes.ts). Each song + * gets one; the pick is in use-scene.ts. * * Drawn at half the screen's resolution, then softened with a blur: glow, * not pixels, and a phone fills a quarter of the pixels a frame. */ -const SCALE = 1 / 2 - -type Colors = { vibrant: string; dominant: string; muted: string; light: string } - -type Painter = (ctx: CanvasRenderingContext2D, w: number, h: number, f: BeatFrame, c: Colors) => void - /** The scene layer, crossfading from one song's scene to the next. */ export function BackdropScene() { const scene = useScene() @@ -37,7 +33,18 @@ export function BackdropScene() { ) } -function SceneCanvas({ paint }: { paint: () => Painter }) { +/** + * A canvas a painter draws on every frame the beat engine runs. Drawn at a + * fraction of the element's size (`scale`) and blurred by its class, so it + * reads as glow. In the app it follows the beat lab's scene strength. + */ +export function SceneCanvas({ paint, scale = 1 / 2, app = true, className = 'opacity-70 blur-md dark:opacity-90' }: { + paint: () => Painter + scale?: number + /** In the app's backdrop, rather than the big visualizer. */ + app?: boolean + className?: string +}) { const ref = useRef(null) useEffect(() => { @@ -47,8 +54,8 @@ function SceneCanvas({ paint }: { paint: () => Painter }) { const draw = paint() const fit = () => { - canvas.width = Math.max(1, Math.round(canvas.clientWidth * SCALE)) - canvas.height = Math.max(1, Math.round(canvas.clientHeight * SCALE)) + canvas.width = Math.max(1, Math.round(canvas.clientWidth * scale)) + canvas.height = Math.max(1, Math.round(canvas.clientHeight * scale)) } fit() const ro = new ResizeObserver(fit) @@ -65,7 +72,7 @@ function SceneCanvas({ paint }: { paint: () => Painter }) { } ctx.clearRect(0, 0, canvas.width, canvas.height) if (f.presence < 0.002) return - const strength = beatSettings.get().effects.scene + const strength = app ? beatSettings.get().effects.scene : 1 if (strength === 0) return ctx.save() ctx.globalAlpha = Math.min(1, f.presence * strength) @@ -76,192 +83,7 @@ function SceneCanvas({ paint }: { paint: () => Painter }) { off() ro.disconnect() } - }, [paint]) - - return -} - -function readColors(el: HTMLElement): Colors { - const s = getComputedStyle(el) - const v = (name: string) => s.getPropertyValue(name).trim() || 'white' - return { vibrant: v('--pal-vibrant'), dominant: v('--pal-dominant'), muted: v('--pal-muted'), light: v('--pal-light') } -} - -/** Eases a value: fast up (attack), slower down (release). */ -function follow(from: number, to: number, dt: number, attack: number, release: number) { - const ms = to > from ? attack : release - return from + (to - from) * (1 - Math.exp(-dt / (ms / 3))) -} - -// --- Scenes ------------------------------------------------------------------ -// Each is a factory, so every canvas gets its own state. - -/** A background equalizer: soft columns along the bottom, the song's spectrum mirrored, bass in the middle. */ -function horizon(): Painter { - const N = 32 - const level = new Float32Array(N) - return (ctx, w, h, f, c) => { - const t = f.now / 1000 - const gap = w / N - const bands = f.spectrum.length - const grad = ctx.createLinearGradient(0, h, 0, h * 0.35) - grad.addColorStop(0, c.dominant) - grad.addColorStop(0.6, c.vibrant) - grad.addColorStop(1, c.light) - ctx.fillStyle = grad - for (let i = 0; i < N; i++) { - const x = (i + 0.5) / N - const d = Math.abs(x - 0.5) * 2 // 0 middle, 1 edges - // Between the two nearest bands. - const b = d * (bands - 1) - const k = Math.min(Math.floor(b), bands - 2) - const shape = f.spectrum[k] * (1 - (b - k)) + f.spectrum[k + 1] * (b - k) - // Neighbouring columns share a band; a little wander tells them apart. - const wander = f.energy * 0.12 * (0.5 + 0.5 * Math.sin(t * (1.3 + (i % 7) * 0.31) + i * 1.7)) - level[i] = follow(level[i], Math.min(1, shape + wander), f.dt, 50, 320) - const bh = h * (0.04 + level[i] * 0.5) - const bw = gap * 0.62 - roundRect(ctx, i * gap + (gap - bw) / 2, h - bh, bw, bh + bw, bw / 2) - } - } -} - -/** One slow ring a bar, spreading from behind the art like a drop in still water. */ -function ripples(): Painter { - type Ring = { born: number; color: keyof Colors } - let rings: Ring[] = [] - let last = Number.NaN - const palette: (keyof Colors)[] = ['vibrant', 'light'] - return (ctx, w, h, f, c) => { - const bar = Math.floor(f.beatIndex / 4) - if (bar !== last) { - if (!Number.isNaN(last)) rings.push({ born: f.now, color: palette[Math.abs(bar) % palette.length] }) - last = bar - } - const life = 5200 - rings = rings.filter((r) => f.now - r.born < life) - const cx = w / 2 - const cy = h * 0.3 - const reach = Math.hypot(w, h) * 0.7 - for (const r of rings) { - const k = (f.now - r.born) / life - const ease = 1 - (1 - k) ** 2.5 - ctx.globalAlpha = f.presence * (1 - k) ** 2 * (0.35 + 0.35 * f.energy) - ctx.strokeStyle = c[r.color] - ctx.lineWidth = h * 0.02 * (1 - k * 0.5) - ctx.beginPath() - ctx.arc(cx, cy, h * 0.05 + ease * reach, 0, Math.PI * 2) - ctx.stroke() - } - } -} - -/** - * Curtains of light hanging from the top of the screen, like the real - * aurora: soft vertical rays whose hems wave slowly. They reach further - * down on the bar and ripple faster when the song is loud. - */ -function aurora(): Painter { - let phase = 0 - const curtains: { top: number; len: number; color: keyof Colors; f: number; o: number }[] = [ - // Back to front: a tall faint veil, the main curtain, then lower, - // shorter folds in the other colors, so it has depth. - { top: -0.02, len: 0.5, color: 'muted', f: 0.6, o: 5.1 }, - { top: 0.05, len: 0.38, color: 'vibrant', f: 1, o: 0 }, - { top: 0.2, len: 0.28, color: 'light', f: 1.6, o: 2.4 }, - { top: 0.34, len: 0.22, color: 'dominant', f: 2.1, o: 3.7 }, - ] - const COLS = 40 - return (ctx, w, h, f, c) => { - phase += (f.dt / 1000) * (0.12 + f.energy * 0.35) - const colW = w / COLS - for (const k of curtains) { - for (let i = 0; i <= COLS; i++) { - const x = i / COLS - // The hem waves; the rays' brightness shimmers along it. - const wave = Math.sin(x * 4 * k.f + phase + k.o) * 0.5 + Math.sin(x * 9 * k.f - phase * 1.7 + k.o) * 0.25 - const shimmer = 0.55 + 0.45 * Math.sin(x * 23 + phase * 2.3 + k.o) ** 2 - const top = (k.top + wave * 0.05) * h - const len = h * k.len * (0.75 + 0.2 * f.energy + 0.3 * f.swell) * (0.8 + 0.2 * shimmer) - const g = ctx.createLinearGradient(0, top, 0, top + len) - g.addColorStop(0, 'transparent') - g.addColorStop(0.55, c[k.color]) - g.addColorStop(1, 'transparent') - ctx.globalAlpha = f.presence * shimmer * (0.3 + 0.25 * f.swell + 0.15 * f.energy) - ctx.fillStyle = g - ctx.fillRect(i * colW - colW, top, colW * 2.2, len) - } - } - } -} - -/** - * The palette mesh, alive: big soft pools of the art's colors that wander, - * swell and brighten once a bar, and wander faster when the song is loud. - */ -function mesh(): Painter { - let phase = Math.random() * 100 - const pools: { color: keyof Colors; x: number; y: number; ax: number; ay: number; fx: number; fy: number; r: number }[] = [ - { color: 'vibrant', x: 0.3, y: 0.25, ax: 0.18, ay: 0.12, fx: 0.7, fy: 0.9, r: 0.55 }, - { color: 'light', x: 0.72, y: 0.45, ax: 0.15, ay: 0.18, fx: 0.5, fy: 0.6, r: 0.45 }, - { color: 'dominant', x: 0.45, y: 0.8, ax: 0.22, ay: 0.1, fx: 0.4, fy: 0.8, r: 0.6 }, - ] - return (ctx, w, h, f, c) => { - phase += (f.dt / 1000) * (0.08 + f.energy * 0.3) - const m = Math.max(w, h) - for (const p of pools) { - const x = (p.x + p.ax * Math.sin(phase * p.fx)) * w - const y = (p.y + p.ay * Math.cos(phase * p.fy)) * h - const r = m * p.r * (1 + 0.22 * f.swell) - const g = ctx.createRadialGradient(x, y, 0, x, y, r) - g.addColorStop(0, c[p.color]) - g.addColorStop(1, 'transparent') - ctx.globalAlpha = f.presence * (0.28 + 0.4 * f.swell + 0.12 * f.energy) - ctx.fillStyle = g - ctx.fillRect(0, 0, w, h) - } - } -} - -/** Embers rising through the glass; they glow on the beat and quicken with energy. */ -function embers(): Painter { - const N = 70 - const roles: (keyof Colors)[] = ['vibrant', 'light', 'vibrant', 'muted'] - const ps = Array.from({ length: N }, () => ({ - x: Math.random(), - y: Math.random(), - r: 0.8 + Math.random() * 1.8, - s: 0.4 + Math.random() * 0.8, - w: Math.random() * Math.PI * 2, - hit: Math.random(), - color: roles[Math.floor(Math.random() * roles.length)], - })) - return (ctx, w, h, f, c) => { - const rise = (f.dt / 1000) * (0.02 + f.energy * 0.09) - for (const p of ps) { - p.y -= rise * p.s * (1 + f.swell * 0.8) - p.w += f.dt / 1400 - if (p.y < -0.05) { - p.y = 1.05 - p.x = Math.random() - } - const x = (p.x + Math.sin(p.w) * 0.02) * w - const y = p.y * h - // Some embers flare on the beat, the rest just drift. - const flare = p.hit > 0.55 ? f.beat * (p.hit - 0.55) * 2.2 : 0 - ctx.globalAlpha = f.presence * Math.min(1, 0.35 + flare + f.energy * 0.2) - ctx.fillStyle = c[p.color] - ctx.beginPath() - ctx.arc(x, y, p.r * (1 + flare * 0.8), 0, Math.PI * 2) - ctx.fill() - } - } -} - -const painters: Record Painter> = { mesh, horizon, ripples, aurora, embers } + }, [paint, scale, app]) -function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { - ctx.beginPath() - ctx.roundRect(x, y, w, h, r) - ctx.fill() + return } diff --git a/web/src/components/shell/beat-lab.tsx b/web/src/components/shell/beat-lab.tsx index 93209a0..092078b 100644 --- a/web/src/components/shell/beat-lab.tsx +++ b/web/src/components/shell/beat-lab.tsx @@ -1,4 +1,4 @@ -import { Activity, RotateCcw, X } from 'lucide-react' +import { Activity, RotateCcw, Sparkles, X } from 'lucide-react' import { Popover } from 'radix-ui' import { useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' @@ -21,6 +21,7 @@ import { setBeatSettings, shiftDownbeat, tap, + visualizerOpen, type BeatOrigin, type Effect, } from '@/lib/beat' @@ -151,6 +152,13 @@ function Look() {

Auto picks a backdrop that suits each song.

+ + +

The song's visuals, full screen. ← and → change the show.

+
) } diff --git a/web/src/components/shell/now-playing-sheet.tsx b/web/src/components/shell/now-playing-sheet.tsx index f2c5b96..e83b28d 100644 --- a/web/src/components/shell/now-playing-sheet.tsx +++ b/web/src/components/shell/now-playing-sheet.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ListMusic, Mic2 } from 'lucide-react' +import { ChevronDown, ListMusic, Mic2, Sparkles } from 'lucide-react' import { AnimatePresence, motion, useDragControls } from 'motion/react' import { Dialog } from 'radix-ui' import { useRef, useState } from 'react' @@ -14,6 +14,7 @@ import { UserAvatar } from '@/components/user-avatar' import { useBeat } from '@/hooks/use-beat' import { usePosition } from '@/hooks/use-position' import { laneStyle } from '@/lib/lane' +import { visualizerOpen } from '@/lib/beat' import { easeOutExpo, spring } from '@/lib/motion' import { formatDuration, usePlayer } from '@/lib/now-playing' import { AlbumBackdrop } from './album-backdrop' @@ -81,6 +82,15 @@ export function NowPlayingSheet({ open, onOpenChange }: { open: boolean; onOpenC {np.roomId ? (
+ +
+ +
+ {np && ( +
+ +
+

{np.track.title}

+

{np.track.artists.join(', ')}

+
+ {formatDuration(position)} + + + + {formatDuration(np.track.durationMs)} +
+
+ {commands.toggle && ( + + )} +
+ )} + +
+ + {setting === 'auto' ? `Auto · ${show}` : show} + +
+
+
+ + ) +} diff --git a/web/src/components/visualizer/visualizer.tsx b/web/src/components/visualizer/visualizer.tsx new file mode 100644 index 0000000..6bc1de1 --- /dev/null +++ b/web/src/components/visualizer/visualizer.tsx @@ -0,0 +1,115 @@ +import { AnimatePresence, motion } from 'motion/react' +import { useEffect, useRef, useState } from 'react' +import { Artwork } from '@/components/artwork' +import { SceneCanvas } from '@/components/shell/backdrop-scene' +import type { Show } from '@/hooks/use-scene' +import { onBeatFrame } from '@/lib/beat' +import { painters, readColors, showPainters } from '@/lib/scenes' +import { cn } from '@/lib/utils' +import { Fluid, toRgb, type FluidColors } from './fluid' + +/** + * The big visualizer (MAD-779, MAD-780): full-screen visuals in the + * album's colors, moving with the song's beat map. For the TV and the + * app's full-window mode. Nothing here is subtle. + */ +export function Visualizer({ show, artworkUrl, className }: { show: Show; artworkUrl?: string; className?: string }) { + return ( +
+ + + + + +
+ ) +} + +// Sharp shows draw at the screen's resolution, up to 1.5 pixels a point. +const sharp = typeof devicePixelRatio === 'number' ? Math.min(devicePixelRatio, 1.5) : 1 + +function ShowLayer({ show, artworkUrl }: { show: Show; artworkUrl?: string }) { + if (show === 'fluid') return + if (show === 'spectrum') { + return ( + <> + + {/* The art sits inside the ring. */} +
+ +
+ + ) + } + if (show === 'starfield') return + // The backdrop's scenes, full strength. + return +} + +/** The palette fluid, or the aurora where there's no WebGL. */ +function FluidCanvas() { + const ref = useRef(null) + const [webgl] = useState(hasWebGL) + + useEffect(() => { + const canvas = ref.current + if (!canvas) return + let fluid: Fluid + try { + fluid = new Fluid(canvas) + } catch (err) { + console.warn('visualizer: no fluid', err) + return + } + let colors: FluidColors | null = null + let readAt = -Infinity + const off = onBeatFrame((f) => { + if (f.now - readAt > 300) { + const c = readColors(canvas) + const dark = getComputedStyle(canvas).getPropertyValue('--pal-dark').trim() || 'black' + colors = { dark: toRgb(dark), dominant: toRgb(c.dominant), vibrant: toRgb(c.vibrant), light: toRgb(c.light) } + readAt = f.now + } + if (colors) fluid.render(f, colors) + }) + return () => { + off() + fluid.dispose() + } + }, []) + + if (!webgl) return + return +} + +let webglChecked: boolean | undefined + +/** Whether the device can draw WebGL; asked once, and the test context let go. */ +function hasWebGL() { + if (webglChecked === undefined) { + try { + const gl = document.createElement('canvas').getContext('webgl') + webglChecked = !!gl + gl?.getExtension('WEBGL_lose_context')?.loseContext() + } catch { + webglChecked = false + } + } + return webglChecked +} diff --git a/web/src/hooks/use-scene.ts b/web/src/hooks/use-scene.ts index 7dfe1d7..4d7d900 100644 --- a/web/src/hooks/use-scene.ts +++ b/web/src/hooks/use-scene.ts @@ -28,3 +28,31 @@ export function useScene(): Scene { if (energy > 0.65 && map.bpm >= 110) return brightness > 0.5 ? pick('horizon', 'ripples') : pick('horizon', 'embers') return pick(...SCENES) } + +/** The big visualizer's shows (MAD-779, MAD-780). */ +export const SHOWS = ['fluid', 'spectrum', 'starfield', 'aurora', 'embers', 'horizon', 'ripples'] as const +export type Show = (typeof SHOWS)[number] + +export function isShow(s: string): s is Show { + return (SHOWS as readonly string[]).includes(s) +} + +/** + * The visualizer's show: `setting` if it names one, else one that suits + * the song playing, like the backdrop's pick. + */ +export function useShow(setting: string): Show { + const { np, map, mapKey } = useStore(beatSource) + if (isShow(setting)) return setting + const key = np?.itemId ?? np?.track.trackId + if (!key) return 'fluid' + let h = 7 + for (const ch of key) h = (h * 33 + ch.charCodeAt(0)) | 0 + const pick = (...from: Show[]) => from[Math.abs(h) % from.length] + if (!map || mapKey !== np?.itemId) return pick('fluid', 'spectrum', 'starfield') + const { energy } = map.features + if (map.bpm === 0) return pick('fluid', 'aurora') + if (energy < 0.4 || map.bpm < 90) return pick('fluid', 'aurora', 'embers') + if (energy > 0.65 && map.bpm >= 110) return pick('spectrum', 'starfield', 'horizon', 'fluid') + return pick(...SHOWS) +} diff --git a/web/src/lib/beat.ts b/web/src/lib/beat.ts index 2696878..1557753 100644 --- a/web/src/lib/beat.ts +++ b/web/src/lib/beat.ts @@ -46,6 +46,8 @@ export type BeatSettings = { intensity: number /** The backdrop scene, or 'auto' to pick one per song. */ scene: string + /** The full-screen visualizer's show, or 'auto'. */ + show: string /** Each effect's strength, 0 (off) – 2. */ effects: Record /** How late this device's sound is, ms: a Bluetooth speaker, say. Visuals wait for it. */ @@ -57,6 +59,7 @@ export const BEAT_DEFAULTS: BeatSettings = { level: 'subtle', intensity: 1, scene: 'auto', + show: 'auto', effects: { breath: 1, scene: 1, drift: 1, art: 1, glass: 1, eq: 1, lyrics: 1, waveform: 1 }, delayMs: 0, } @@ -88,6 +91,7 @@ function readSettings(): BeatSettings { level: s.level === 'off' ? 'off' : 'subtle', intensity: clamp(s.intensity, 0.5, 1.75, BEAT_DEFAULTS.intensity), scene: typeof s.scene === 'string' ? s.scene : BEAT_DEFAULTS.scene, + show: typeof s.show === 'string' ? s.show : BEAT_DEFAULTS.show, effects, delayMs: clamp(s.delayMs, -300, 600, 0), } @@ -107,6 +111,9 @@ function applyEffects(s: BeatSettings) { root.toggleAttribute('data-fx-eq-off', s.effects.eq === 0) } +/** Whether the full-screen visualizer is open in the app. */ +export const visualizerOpen = createStore(false) + /** Tempo for songs with no beat map yet. */ const DEFAULT_BPM = 120 diff --git a/web/src/lib/room-socket.ts b/web/src/lib/room-socket.ts index 0e13b8c..6614781 100644 --- a/web/src/lib/room-socket.ts +++ b/web/src/lib/room-socket.ts @@ -4,6 +4,7 @@ import type { components } from '@/api/schema.gen' import { invitesQuery, membersQuery } from './access' import { meQuery } from './auth' import { syncServerClock } from './clock' +import { displayMeQuery } from './displays' import { guestPassQuery, guestsQuery } from './guests' import { crown, heartsQuery, nightsQuery, type Hearts, type Night } from './nights' import { newer, playbackQuery, type Playback } from './playback' @@ -154,6 +155,8 @@ function handle(queryClient: QueryClient, roomId: string, ev: RoomEvent) { case 'room.updated': { const room = ev.data as Room queryClient.setQueryData(roomsQuery.queryKey, (rs) => rs?.map((r) => (r.id === room.id ? room : r))) + // A paired screen knows its room from its own record: its look follows the room's. + queryClient.setQueryData(displayMeQuery.queryKey, (d) => (d && d.room.id === room.id ? { ...d, room } : d)) break } case 'room.deleted': { diff --git a/web/src/lib/scenes.ts b/web/src/lib/scenes.ts new file mode 100644 index 0000000..2a0b7c4 --- /dev/null +++ b/web/src/lib/scenes.ts @@ -0,0 +1,295 @@ +import type { BeatFrame } from './beat' + +// Scene painters: canvas drawings of the music in the art's colors, for +// the app's backdrop (components/shell/backdrop-scene.tsx) and the big +// visualizer (components/visualizer). Each is a factory, so every canvas +// gets its own state. + +export type Colors = { vibrant: string; dominant: string; muted: string; light: string } + +export type Painter = (ctx: CanvasRenderingContext2D, w: number, h: number, f: BeatFrame, c: Colors) => void + +/** The palette as the element sees it (it eases between songs). */ +export function readColors(el: HTMLElement): Colors { + const s = getComputedStyle(el) + const v = (name: string) => s.getPropertyValue(name).trim() || 'white' + return { vibrant: v('--pal-vibrant'), dominant: v('--pal-dominant'), muted: v('--pal-muted'), light: v('--pal-light') } +} + +/** Eases a value: fast up (attack), slower down (release). */ +export function follow(from: number, to: number, dt: number, attack: number, release: number) { + const ms = to > from ? attack : release + return from + (to - from) * (1 - Math.exp(-dt / (ms / 3))) +} + +/** A background equalizer: soft columns along the bottom, the song's spectrum mirrored, bass in the middle. */ +function horizon(): Painter { + const N = 32 + const level = new Float32Array(N) + return (ctx, w, h, f, c) => { + const t = f.now / 1000 + const gap = w / N + const bands = f.spectrum.length + const grad = ctx.createLinearGradient(0, h, 0, h * 0.35) + grad.addColorStop(0, c.dominant) + grad.addColorStop(0.6, c.vibrant) + grad.addColorStop(1, c.light) + ctx.fillStyle = grad + for (let i = 0; i < N; i++) { + const x = (i + 0.5) / N + const d = Math.abs(x - 0.5) * 2 // 0 middle, 1 edges + // Between the two nearest bands. + const b = d * (bands - 1) + const k = Math.min(Math.floor(b), bands - 2) + const shape = f.spectrum[k] * (1 - (b - k)) + f.spectrum[k + 1] * (b - k) + // Neighbouring columns share a band; a little wander tells them apart. + const wander = f.energy * 0.12 * (0.5 + 0.5 * Math.sin(t * (1.3 + (i % 7) * 0.31) + i * 1.7)) + level[i] = follow(level[i], Math.min(1, shape + wander), f.dt, 50, 320) + const bh = h * (0.04 + level[i] * 0.5) + const bw = gap * 0.62 + roundRect(ctx, i * gap + (gap - bw) / 2, h - bh, bw, bh + bw, bw / 2) + } + } +} + +/** One slow ring a bar, spreading from behind the art like a drop in still water. */ +function ripples(): Painter { + type Ring = { born: number; color: keyof Colors } + let rings: Ring[] = [] + let last = Number.NaN + const palette: (keyof Colors)[] = ['vibrant', 'light'] + return (ctx, w, h, f, c) => { + const bar = Math.floor(f.beatIndex / 4) + if (bar !== last) { + if (!Number.isNaN(last)) rings.push({ born: f.now, color: palette[Math.abs(bar) % palette.length] }) + last = bar + } + const life = 5200 + rings = rings.filter((r) => f.now - r.born < life) + const cx = w / 2 + const cy = h * 0.3 + const reach = Math.hypot(w, h) * 0.7 + for (const r of rings) { + const k = (f.now - r.born) / life + const ease = 1 - (1 - k) ** 2.5 + ctx.globalAlpha = f.presence * (1 - k) ** 2 * (0.35 + 0.35 * f.energy) + ctx.strokeStyle = c[r.color] + ctx.lineWidth = h * 0.02 * (1 - k * 0.5) + ctx.beginPath() + ctx.arc(cx, cy, h * 0.05 + ease * reach, 0, Math.PI * 2) + ctx.stroke() + } + } +} + +/** + * Curtains of light hanging from the top of the screen, like the real + * aurora: soft vertical rays whose hems wave slowly. They reach further + * down on the bar and ripple faster when the song is loud. + */ +function aurora(): Painter { + let phase = 0 + const curtains: { top: number; len: number; color: keyof Colors; f: number; o: number }[] = [ + // Back to front: a tall faint veil, the main curtain, then lower, + // shorter folds in the other colors, so it has depth. + { top: -0.02, len: 0.5, color: 'muted', f: 0.6, o: 5.1 }, + { top: 0.05, len: 0.38, color: 'vibrant', f: 1, o: 0 }, + { top: 0.2, len: 0.28, color: 'light', f: 1.6, o: 2.4 }, + { top: 0.34, len: 0.22, color: 'dominant', f: 2.1, o: 3.7 }, + ] + const COLS = 40 + return (ctx, w, h, f, c) => { + phase += (f.dt / 1000) * (0.12 + f.energy * 0.35) + const colW = w / COLS + for (const k of curtains) { + for (let i = 0; i <= COLS; i++) { + const x = i / COLS + // The hem waves; the rays' brightness shimmers along it. + const wave = Math.sin(x * 4 * k.f + phase + k.o) * 0.5 + Math.sin(x * 9 * k.f - phase * 1.7 + k.o) * 0.25 + const shimmer = 0.55 + 0.45 * Math.sin(x * 23 + phase * 2.3 + k.o) ** 2 + const top = (k.top + wave * 0.05) * h + const len = h * k.len * (0.75 + 0.2 * f.energy + 0.3 * f.swell) * (0.8 + 0.2 * shimmer) + const g = ctx.createLinearGradient(0, top, 0, top + len) + g.addColorStop(0, 'transparent') + g.addColorStop(0.55, c[k.color]) + g.addColorStop(1, 'transparent') + ctx.globalAlpha = f.presence * shimmer * (0.3 + 0.25 * f.swell + 0.15 * f.energy) + ctx.fillStyle = g + ctx.fillRect(i * colW - colW, top, colW * 2.2, len) + } + } + } +} + +/** + * The palette mesh, alive: big soft pools of the art's colors that wander, + * swell and brighten once a bar, and wander faster when the song is loud. + */ +function mesh(): Painter { + let phase = Math.random() * 100 + const pools: { color: keyof Colors; x: number; y: number; ax: number; ay: number; fx: number; fy: number; r: number }[] = [ + { color: 'vibrant', x: 0.3, y: 0.25, ax: 0.18, ay: 0.12, fx: 0.7, fy: 0.9, r: 0.55 }, + { color: 'light', x: 0.72, y: 0.45, ax: 0.15, ay: 0.18, fx: 0.5, fy: 0.6, r: 0.45 }, + { color: 'dominant', x: 0.45, y: 0.8, ax: 0.22, ay: 0.1, fx: 0.4, fy: 0.8, r: 0.6 }, + ] + return (ctx, w, h, f, c) => { + phase += (f.dt / 1000) * (0.08 + f.energy * 0.3) + const m = Math.max(w, h) + for (const p of pools) { + const x = (p.x + p.ax * Math.sin(phase * p.fx)) * w + const y = (p.y + p.ay * Math.cos(phase * p.fy)) * h + const r = m * p.r * (1 + 0.22 * f.swell) + const g = ctx.createRadialGradient(x, y, 0, x, y, r) + g.addColorStop(0, c[p.color]) + g.addColorStop(1, 'transparent') + ctx.globalAlpha = f.presence * (0.28 + 0.4 * f.swell + 0.12 * f.energy) + ctx.fillStyle = g + ctx.fillRect(0, 0, w, h) + } + } +} + +/** Embers rising through the glass; they glow on the beat and quicken with energy. */ +function embers(): Painter { + const N = 70 + const roles: (keyof Colors)[] = ['vibrant', 'light', 'vibrant', 'muted'] + const ps = Array.from({ length: N }, () => ({ + x: Math.random(), + y: Math.random(), + r: 0.8 + Math.random() * 1.8, + s: 0.4 + Math.random() * 0.8, + w: Math.random() * Math.PI * 2, + hit: Math.random(), + color: roles[Math.floor(Math.random() * roles.length)], + })) + return (ctx, w, h, f, c) => { + const rise = (f.dt / 1000) * (0.02 + f.energy * 0.09) + for (const p of ps) { + p.y -= rise * p.s * (1 + f.swell * 0.8) + p.w += f.dt / 1400 + if (p.y < -0.05) { + p.y = 1.05 + p.x = Math.random() + } + const x = (p.x + Math.sin(p.w) * 0.02) * w + const y = p.y * h + // Some embers flare on the beat, the rest just drift. + const flare = p.hit > 0.55 ? f.beat * (p.hit - 0.55) * 2.2 : 0 + ctx.globalAlpha = f.presence * Math.min(1, 0.35 + flare + f.energy * 0.2) + ctx.fillStyle = c[p.color] + ctx.beginPath() + ctx.arc(x, y, p.r * (1 + flare * 0.8), 0, Math.PI * 2) + ctx.fill() + } + } +} + +/** + * The song's spectrum as a ring of bars around the middle of the screen + * (the album art sits inside it), mirrored left and right, bass at the + * top. The ring swells on the bar and the bars flare on the beat. + * For the big visualizer: drawn at full resolution. + */ +function spectrum(): Painter { + const N = 72 + const level = new Float32Array(N) + let spin = 0 + return (ctx, w, h, f, c) => { + const cx = w / 2 + const cy = h / 2 + const m = Math.min(w, h) + const inner = m * (0.2 + 0.012 * f.swell) + const reach = m * 0.2 + const bands = f.spectrum.length + spin += (f.dt / 1000) * (0.03 + f.energy * 0.08) + // A soft glow behind the ring. + const glow = ctx.createRadialGradient(cx, cy, inner * 0.8, cx, cy, inner + reach * 1.4) + glow.addColorStop(0, c.vibrant) + glow.addColorStop(1, 'transparent') + ctx.globalAlpha = f.presence * (0.18 + 0.25 * f.swell) + ctx.fillStyle = glow + ctx.fillRect(0, 0, w, h) + ctx.globalAlpha = f.presence + ctx.lineCap = 'round' + const width = ((Math.PI * 2 * inner) / N) * 0.55 + for (let i = 0; i < N; i++) { + // Mirrored: 0 at the top through the band range down each side. + const half = i < N / 2 ? i / (N / 2) : (N - i) / (N / 2) + const b = half * (bands - 1) + const k = Math.min(Math.floor(b), bands - 2) + const v = f.spectrum[k] * (1 - (b - k)) + f.spectrum[k + 1] * (b - k) + level[i] = follow(level[i], v, f.dt, 40, 260) + const len = reach * (0.06 + level[i] * 0.94) * (1 + f.beat * 0.15) + const a = (i / N) * Math.PI * 2 - Math.PI / 2 + spin + const cos = Math.cos(a) + const sin = Math.sin(a) + ctx.strokeStyle = level[i] > 0.75 ? c.light : c.vibrant + ctx.lineWidth = width + ctx.beginPath() + ctx.moveTo(cx + cos * inner, cy + sin * inner) + ctx.lineTo(cx + cos * (inner + len), cy + sin * (inner + len)) + ctx.stroke() + } + } +} + +/** + * Flying through stars in the art's colors: faster when the song is loud, + * a surge on each bar, streaks that lengthen with the speed. + */ +function starfield(): Painter { + const N = 260 + const roles: (keyof Colors)[] = ['light', 'vibrant', 'light', 'muted'] + const stars = Array.from({ length: N }, () => ({ + x: Math.random() * 2 - 1, + y: Math.random() * 2 - 1, + z: Math.random(), + color: roles[Math.floor(Math.random() * roles.length)], + })) + let surge = 0 + return (ctx, w, h, f, c) => { + surge = Math.max(surge * Math.exp(-f.dt / 350), f.downbeat) + const speed = (f.dt / 1000) * (0.08 + f.energy * 0.35 + surge * 0.5) + const cx = w / 2 + const cy = h / 2 + const scale = Math.max(w, h) * 0.5 + ctx.lineCap = 'round' + for (const s of stars) { + const pz = s.z + s.z -= speed + if (s.z <= 0.02) { + s.x = Math.random() * 2 - 1 + s.y = Math.random() * 2 - 1 + s.z = 1 + continue + } + const x = cx + (s.x / s.z) * scale * 0.3 + const y = cy + (s.y / s.z) * scale * 0.3 + const px = cx + (s.x / pz) * scale * 0.3 + const py = cy + (s.y / pz) * scale * 0.3 + if (x < -50 || x > w + 50 || y < -50 || y > h + 50) { + s.z = 0 + continue + } + const near = 1 - s.z + ctx.globalAlpha = f.presence * Math.min(1, near * 1.4) * (0.6 + 0.4 * f.beat) + ctx.strokeStyle = c[s.color] + ctx.lineWidth = Math.max(1, near * near * 5) + ctx.beginPath() + ctx.moveTo(px, py) + ctx.lineTo(x, y) + ctx.stroke() + } + } +} + +export const painters = { mesh, horizon, ripples, aurora, embers } + +/** The visualizer's own canvas shows, drawn sharp at full size. */ +export const showPainters = { spectrum, starfield } + +function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { + ctx.beginPath() + ctx.roundRect(x, y, w, h, r) + ctx.fill() +} diff --git a/web/src/routes/tv.tsx b/web/src/routes/tv.tsx index 23c1a32..2d41667 100644 --- a/web/src/routes/tv.tsx +++ b/web/src/routes/tv.tsx @@ -37,6 +37,7 @@ function PairedStage({ me }: { me: DisplayMe }) { queryClient.setQueryData(displayMeQuery.queryKey, null)} audio={ @@ -63,6 +64,7 @@ function SignedInStage() { {}} audio={maySpeak ? { device: deviceId(), name: `${me.displayName.split(' ')[0]}'s big screen` } : undefined} From c92dfd1b141b3e22aa63160f4dad91f9c8197b3e Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 22:03:36 -0500 Subject: [PATCH 06/15] Visualizer: fix the fluid, crank up the shows (MAD-779, MAD-780) The fluid drew nothing in development: strict mode mounts it twice, and the first unmount let go of the canvas's WebGL context, which the second mount then couldn't use. Each mount now gets a canvas of its own. The shader also asks for full precision where there is any, since phones run mediump at half precision. The starfield becomes a tunnel of the song's own spectrum: a ring cast in its shape every 30 ms flies at you, with a bright ring on each bar. On the visualizer, ripples become rain (ripples all over, a burst on each bar, the treble flicking tiny ones in between) and embers become sparks (hundreds of glowing embers, a fountain on each bar). The app's backdrop keeps its calmer ripples and embers. Co-Authored-By: Claude Opus 5.5 --- web/src/components/visualizer/fluid.ts | 6 + web/src/components/visualizer/visualizer.tsx | 25 +- web/src/hooks/use-scene.ts | 8 +- web/src/lib/scenes.ts | 232 +++++++++++++++---- 4 files changed, 218 insertions(+), 53 deletions(-) diff --git a/web/src/components/visualizer/fluid.ts b/web/src/components/visualizer/fluid.ts index 4e23291..b6feae3 100644 --- a/web/src/components/visualizer/fluid.ts +++ b/web/src/components/visualizer/fluid.ts @@ -16,7 +16,13 @@ void main() { gl_Position = vec4(p, 0.0, 1.0); } ` const FRAGMENT = ` +// Full precision where there is any: phones run mediump at half +// precision, which turns the noise's hash to mush. +#ifdef GL_FRAGMENT_PRECISION_HIGH +precision highp float; +#else precision mediump float; +#endif uniform vec2 uRes; uniform float uTime; uniform vec3 uDark, uDominant, uVibrant, uLight; diff --git a/web/src/components/visualizer/visualizer.tsx b/web/src/components/visualizer/visualizer.tsx index 6bc1de1..392d78b 100644 --- a/web/src/components/visualizer/visualizer.tsx +++ b/web/src/components/visualizer/visualizer.tsx @@ -57,32 +57,40 @@ function ShowLayer({ show, artworkUrl }: { show: Show; artworkUrl?: string }) { ) } - if (show === 'starfield') return + if (show === 'tunnel' || show === 'rain' || show === 'sparks') { + return + } // The backdrop's scenes, full strength. return } /** The palette fluid, or the aurora where there's no WebGL. */ function FluidCanvas() { - const ref = useRef(null) + const ref = useRef(null) const [webgl] = useState(hasWebGL) useEffect(() => { - const canvas = ref.current - if (!canvas) return + const box = ref.current + if (!box || !webgl) return + // A canvas of its own each time: a context let go can't be had back, + // and React may mount this twice (strict mode). + const canvas = document.createElement('canvas') + canvas.className = 'absolute inset-0 size-full' + box.append(canvas) let fluid: Fluid try { fluid = new Fluid(canvas) } catch (err) { console.warn('visualizer: no fluid', err) + canvas.remove() return } let colors: FluidColors | null = null let readAt = -Infinity const off = onBeatFrame((f) => { if (f.now - readAt > 300) { - const c = readColors(canvas) - const dark = getComputedStyle(canvas).getPropertyValue('--pal-dark').trim() || 'black' + const c = readColors(box) + const dark = getComputedStyle(box).getPropertyValue('--pal-dark').trim() || 'black' colors = { dark: toRgb(dark), dominant: toRgb(c.dominant), vibrant: toRgb(c.vibrant), light: toRgb(c.light) } readAt = f.now } @@ -91,11 +99,12 @@ function FluidCanvas() { return () => { off() fluid.dispose() + canvas.remove() } - }, []) + }, [webgl]) if (!webgl) return - return + return
} let webglChecked: boolean | undefined diff --git a/web/src/hooks/use-scene.ts b/web/src/hooks/use-scene.ts index 4d7d900..cdb0f62 100644 --- a/web/src/hooks/use-scene.ts +++ b/web/src/hooks/use-scene.ts @@ -30,7 +30,7 @@ export function useScene(): Scene { } /** The big visualizer's shows (MAD-779, MAD-780). */ -export const SHOWS = ['fluid', 'spectrum', 'starfield', 'aurora', 'embers', 'horizon', 'ripples'] as const +export const SHOWS = ['spectrum', 'fluid', 'tunnel', 'horizon', 'rain', 'sparks', 'aurora'] as const export type Show = (typeof SHOWS)[number] export function isShow(s: string): s is Show { @@ -49,10 +49,10 @@ export function useShow(setting: string): Show { let h = 7 for (const ch of key) h = (h * 33 + ch.charCodeAt(0)) | 0 const pick = (...from: Show[]) => from[Math.abs(h) % from.length] - if (!map || mapKey !== np?.itemId) return pick('fluid', 'spectrum', 'starfield') + if (!map || mapKey !== np?.itemId) return pick('spectrum', 'fluid', 'tunnel') const { energy } = map.features if (map.bpm === 0) return pick('fluid', 'aurora') - if (energy < 0.4 || map.bpm < 90) return pick('fluid', 'aurora', 'embers') - if (energy > 0.65 && map.bpm >= 110) return pick('spectrum', 'starfield', 'horizon', 'fluid') + if (energy < 0.4 || map.bpm < 90) return pick('fluid', 'aurora', 'rain') + if (energy > 0.65 && map.bpm >= 110) return pick('spectrum', 'tunnel', 'horizon', 'sparks') return pick(...SHOWS) } diff --git a/web/src/lib/scenes.ts b/web/src/lib/scenes.ts index 2a0b7c4..4f06a0f 100644 --- a/web/src/lib/scenes.ts +++ b/web/src/lib/scenes.ts @@ -233,60 +233,210 @@ function spectrum(): Painter { } } +/** The spectrum's value at x (0–1 across the bands), between the two nearest. */ +function bandAt(spec: ArrayLike, x: number) { + const b = Math.max(0, Math.min(1, x)) * (spec.length - 1) + const k = Math.min(Math.floor(b), spec.length - 2) + return spec[k] * (1 - (b - k)) + spec[k + 1] * (b - k) +} + /** - * Flying through stars in the art's colors: faster when the song is loud, - * a surge on each bar, streaks that lengthen with the speed. + * A tunnel of the song's own spectrum: every few hundredths of a second + * a ring is cast in the shape of the spectrum at that moment and flies at + * you, so the tunnel walls are the song's recent past. Bars' first beats + * cast a bright ring; the whole thing turns faster when the song is loud. */ -function starfield(): Painter { - const N = 260 - const roles: (keyof Colors)[] = ['light', 'vibrant', 'light', 'muted'] - const stars = Array.from({ length: N }, () => ({ - x: Math.random() * 2 - 1, - y: Math.random() * 2 - 1, - z: Math.random(), - color: roles[Math.floor(Math.random() * roles.length)], - })) - let surge = 0 +function tunnel(): Painter { + type Ring = { z: number; shape: Float32Array; bright: boolean; hue: number } + const POINTS = 96 + let rings: Ring[] = [] + let castAt = 0 + let lastBar = Number.NaN + let spin = 0 + let wander = 0 return (ctx, w, h, f, c) => { - surge = Math.max(surge * Math.exp(-f.dt / 350), f.downbeat) - const speed = (f.dt / 1000) * (0.08 + f.energy * 0.35 + surge * 0.5) - const cx = w / 2 - const cy = h / 2 - const scale = Math.max(w, h) * 0.5 - ctx.lineCap = 'round' - for (const s of stars) { - const pz = s.z - s.z -= speed - if (s.z <= 0.02) { - s.x = Math.random() * 2 - 1 - s.y = Math.random() * 2 - 1 - s.z = 1 - continue - } - const x = cx + (s.x / s.z) * scale * 0.3 - const y = cy + (s.y / s.z) * scale * 0.3 - const px = cx + (s.x / pz) * scale * 0.3 - const py = cy + (s.y / pz) * scale * 0.3 - if (x < -50 || x > w + 50 || y < -50 || y > h + 50) { - s.z = 0 - continue + const travel = (f.dt / 1000) * (0.32 + f.energy * 0.55 + f.swell * 0.2) + for (const r of rings) r.z -= travel + rings = rings.filter((r) => r.z > 0.02) + const bar = Math.floor(f.beatIndex / 4) + const downbeat = f.downbeat > 0.5 && bar !== lastBar + if (downbeat) lastBar = bar + if (f.now - castAt > 30 || downbeat) { + castAt = f.now + const shape = new Float32Array(POINTS) + for (let i = 0; i < POINTS; i++) { + // Mirrored around the ring: bass on two opposite sides, treble between. + const a = i / POINTS + shape[i] = bandAt(f.spectrum, 1 - Math.abs(Math.cos(a * Math.PI * 2))) } - const near = 1 - s.z - ctx.globalAlpha = f.presence * Math.min(1, near * 1.4) * (0.6 + 0.4 * f.beat) - ctx.strokeStyle = c[s.color] - ctx.lineWidth = Math.max(1, near * near * 5) + rings.push({ z: 1, shape, bright: downbeat, hue: rings.length }) + } + spin += (f.dt / 1000) * (0.05 + f.energy * 0.25) + f.beat * 0.002 + wander += f.dt / 1000 + const m = Math.min(w, h) + const cx = w / 2 + Math.sin(wander * 0.21) * m * 0.06 + const cy = h / 2 + Math.cos(wander * 0.17) * m * 0.05 + ctx.globalCompositeOperation = 'lighter' + ctx.lineJoin = 'round' + // Far to near, so the near rings draw over. + for (let j = 0; j < rings.length; j++) { + const r = rings[j] + const radius = (m * 0.045) / r.z + if (radius > Math.hypot(w, h)) continue + const near = 1 - r.z + ctx.globalAlpha = f.presence * Math.min(1, near * 1.8) * (r.bright ? 1 : 0.32) * Math.min(1, r.z * 5) + ctx.strokeStyle = r.bright ? c.light : near > 0.45 ? c.vibrant : c.dominant + ctx.lineWidth = Math.max(0.75, (r.bright ? 3 : 1) * near * near * 3.5) ctx.beginPath() - ctx.moveTo(px, py) - ctx.lineTo(x, y) + for (let i = 0; i <= POINTS; i++) { + const a = (i / POINTS) * Math.PI * 2 + spin + r.z * 0.8 + const k = 1 + r.shape[i % POINTS] * 0.3 + const x = cx + Math.cos(a) * radius * k + const y = cy + Math.sin(a) * radius * k + if (i === 0) ctx.moveTo(x, y) + else ctx.lineTo(x, y) + } ctx.stroke() } + ctx.globalCompositeOperation = 'source-over' + } +} + +/** + * Rain on dark water: ripples open all over the screen, a few on every + * beat and a burst of them on each bar, with the treble flicking tiny + * ones in between. + */ +function rain(): Painter { + type Drop = { x: number; y: number; born: number; size: number; life: number; color: keyof Colors } + let drops: Drop[] = [] + let lastBeat = Number.NaN + let flick = 0 + const roles: (keyof Colors)[] = ['vibrant', 'light', 'vibrant', 'muted'] + const drop = (f: BeatFrame, size: number, life: number, near?: { x: number; y: number }) => { + drops.push({ + x: near ? near.x + (Math.random() - 0.5) * 0.2 : Math.random(), + y: near ? near.y + (Math.random() - 0.5) * 0.2 : Math.random(), + born: f.now, + size: size * (0.7 + Math.random() * 0.6), + life: life * (0.8 + Math.random() * 0.4), + color: roles[Math.floor(Math.random() * roles.length)], + }) + } + return (ctx, w, h, f, c) => { + if (f.beatIndex !== lastBeat) { + lastBeat = f.beatIndex + const one = ((f.beatIndex % 4) + 4) % 4 === 0 + const count = Math.round((one ? 6 : 2) + f.energy * (one ? 6 : 3)) + const at = { x: 0.2 + Math.random() * 0.6, y: 0.2 + Math.random() * 0.6 } + if (one) drop(f, 0.55, 3600, at) + for (let i = 0; i < count; i++) drop(f, one ? 0.22 : 0.16, 2400, one ? at : undefined) + } + // The treble rains between the beats. + const treble = f.bands[3] + flick += (f.dt / 1000) * treble * treble * 14 + while (flick > 1) { + flick -= 1 + drop(f, 0.06, 1400) + } + drops = drops.filter((d) => f.now - d.born < d.life).slice(-140) + const m = Math.min(w, h) + ctx.globalCompositeOperation = 'lighter' + for (const d of drops) { + const k = (f.now - d.born) / d.life + const ease = 1 - (1 - k) ** 2.2 + const r = m * d.size * ease + ctx.strokeStyle = c[d.color] + // A ring and a fainter one inside it, like water. + for (const [scale, alpha] of [ + [1, 0.85], + [0.62, 0.4], + ] as const) { + ctx.globalAlpha = f.presence * (1 - k) ** 1.6 * alpha + ctx.lineWidth = Math.max(1, m * 0.006 * (1 - k)) + ctx.beginPath() + ctx.ellipse(d.x * w, d.y * h, r * scale, r * scale * 0.92, 0, 0, Math.PI * 2) + ctx.stroke() + } + } + ctx.globalCompositeOperation = 'source-over' + } +} + +/** + * Sparks: hundreds of glowing embers swirling up, quicker with the + * energy, flaring on the beat, and a fountain of them from below on each + * bar. + */ +function sparks(): Painter { + type Spark = { x: number; y: number; vx: number; vy: number; size: number; heat: number; color: keyof Colors } + const roles: (keyof Colors)[] = ['vibrant', 'light', 'vibrant', 'dominant'] + const make = (burst: boolean): Spark => ({ + x: burst ? 0.5 + (Math.random() - 0.5) * 0.35 : Math.random(), + y: burst ? 1.02 : Math.random() * 1.1, + vx: burst ? (Math.random() - 0.5) * 0.9 : 0, + vy: burst ? -(0.6 + Math.random() * 0.7) : 0, + size: 0.6 + Math.random() * 1.4, + heat: burst ? 1 : Math.random(), + color: roles[Math.floor(Math.random() * roles.length)], + }) + let sparks = Array.from({ length: 320 }, () => make(false)) + let lastBar = Number.NaN + let t = 0 + // A soft glow per color, drawn once and stamped for every spark. + const sprites = new Map() + const sprite = (color: string) => { + let s = sprites.get(color) + if (!s) { + s = document.createElement('canvas') + s.width = s.height = 64 + const g = s.getContext('2d')! + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32) + grad.addColorStop(0, color) + grad.addColorStop(0.25, color) + grad.addColorStop(1, 'transparent') + g.fillStyle = grad + g.fillRect(0, 0, 64, 64) + if (sprites.size > 24) sprites.clear() + sprites.set(color, s) + } + return s + } + return (ctx, w, h, f, c) => { + const dt = f.dt / 1000 + t += dt + const bar = Math.floor(f.beatIndex / 4) + if (f.downbeat > 0.5 && bar !== lastBar) { + lastBar = bar + sparks.push(...Array.from({ length: Math.round(30 + f.energy * 40) }, () => make(true))) + } + const lift = 0.05 + f.energy * 0.22 + f.swell * 0.08 + const m = Math.min(w, h) + ctx.globalCompositeOperation = 'lighter' + for (const p of sparks) { + // Rising, swirling in a slow turbulence; fountain sparks arc and slow. + p.vx += Math.sin(p.y * 7 + t * 0.9) * dt * 0.08 + p.vx *= 1 - dt * 0.8 + p.vy += dt * 0.45 * (p.vy < -lift ? 1 : 0) + p.x += p.vx * dt + p.y += (Math.min(p.vy, 0) - lift * (0.5 + p.size * 0.4)) * dt + p.heat = Math.max(0.15, p.heat - dt * 0.25) + if (p.y < -0.05 || p.x < -0.05 || p.x > 1.05) Object.assign(p, make(false), { y: 1.05 }) + const flare = f.beat * (p.size > 1.4 ? 0.9 : 0.35) + const r = m * 0.012 * p.size * (1 + flare + f.bands[0] * 0.5) + ctx.globalAlpha = f.presence * Math.min(1, 0.25 + p.heat * 0.6 + flare) + ctx.drawImage(sprite(c[p.color]), p.x * w - r * 2, p.y * h - r * 2, r * 4, r * 4) + } + // Fountains thin out to the usual crowd. + if (sparks.length > 320) sparks = sparks.filter((p, i) => i < 320 || p.heat > 0.2) + ctx.globalCompositeOperation = 'source-over' } } export const painters = { mesh, horizon, ripples, aurora, embers } /** The visualizer's own canvas shows, drawn sharp at full size. */ -export const showPainters = { spectrum, starfield } +export const showPainters = { spectrum, tunnel, rain, sparks } function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { ctx.beginPath() From 2bfea5d859d514678fe9fe81a3b0f9ec89c648b2 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 22:07:53 -0500 Subject: [PATCH 07/15] Stop the visuals flickering when the song changes Every song change passes through the loading state, sometimes more than once, and the engine treated each as a pause: the backdrop, glow and scene faded out and back in, which with now playing open filled the screen. Now the backdrop holds for a second and a half of not playing before it fades, while only the beat's pulses stop at once. The scene and visualizer show stay put until the new song's beat map lands, rather than switching on the song and again on its map; no default tempo is guessed in between; and the waveform keeps its space as a flat line until the map arrives, so the player doesn't jump. Co-Authored-By: Claude Opus 5.5 --- web/src/components/shell/beat-lab.tsx | 4 ++-- web/src/components/shell/waveform.tsx | 11 +++++----- web/src/hooks/use-beat-sync.ts | 3 ++- web/src/hooks/use-scene.ts | 13 +++++++---- web/src/lib/beat.ts | 31 ++++++++++++++++++++++----- 5 files changed, 45 insertions(+), 17 deletions(-) diff --git a/web/src/components/shell/beat-lab.tsx b/web/src/components/shell/beat-lab.tsx index 092078b..cf9b39c 100644 --- a/web/src/components/shell/beat-lab.tsx +++ b/web/src/components/shell/beat-lab.tsx @@ -192,10 +192,10 @@ function Effects() { function Beat() { const { delayMs } = useStore(beatSettings) - const { map, mapKey, np } = useStore(beatSource) + const { mapKey, np } = useStore(beatSource) useStore(gridVersion) const now = currentBeat() - const loading = !!np?.itemId && mapKey !== np.itemId && !map + const loading = !!np?.itemId && mapKey !== np.itemId const [heard, setHeard] = useState(0) const [taps, setTaps] = useState(0) const canCalibrate = now.origin === 'analysed' && !!np && !np.paused diff --git a/web/src/components/shell/waveform.tsx b/web/src/components/shell/waveform.tsx index 47977a4..787090a 100644 --- a/web/src/components/shell/waveform.tsx +++ b/web/src/components/shell/waveform.tsx @@ -7,8 +7,9 @@ const BARS = 72 /** * The song's shape above the seek bar (MAD-777): its loudness from the beat - * map, filled in the album color up to where it's playing. Nothing until - * the map arrives, or if the beat lab turns it off. + * map, filled in the album color up to where it's playing. A flat line + * until the map arrives (it keeps its space, so nothing jumps), and + * nothing if the beat lab turns it off. */ export function Waveform({ itemId, positionMs, durationMs, className }: { itemId?: string @@ -21,7 +22,7 @@ export function Waveform({ itemId, positionMs, durationMs, className }: { const ours = map && mapKey === itemId ? map : null const heights = useMemo(() => { - if (!ours || ours.loudness.length === 0) return null + if (!ours || ours.loudness.length === 0) return Array(BARS).fill(0.12) const per = ours.loudness.length / BARS const out: number[] = [] for (let b = 0; b < BARS; b++) { @@ -35,7 +36,7 @@ export function Waveform({ itemId, positionMs, durationMs, className }: { return out }, [ours]) - if (!heights || level === 'off' || effects.waveform === 0) return null + if (level === 'off' || effects.waveform === 0) return null const played = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0 const bars = ( @@ -45,7 +46,7 @@ export function Waveform({ itemId, positionMs, durationMs, className }: { ) return ( -
+
{bars}
{ - if (!stale) beatSource.set((s) => ({ ...s, map, mapKey: map ? itemId : null })) + if (!stale) beatSource.set((s) => ({ ...s, map, mapKey: itemId })) }) return () => { stale = true diff --git a/web/src/hooks/use-scene.ts b/web/src/hooks/use-scene.ts index cdb0f62..99f5974 100644 --- a/web/src/hooks/use-scene.ts +++ b/web/src/hooks/use-scene.ts @@ -4,6 +4,11 @@ import { useStore } from '@/lib/store' export const SCENES = ['mesh', 'horizon', 'ripples', 'aurora', 'embers'] as const export type Scene = (typeof SCENES)[number] +// Picks follow the song whose beat map has settled (mapKey), not the one +// that just started: while the new song's map is on its way the old pick +// stays, rather than picking now and again when the map lands (two +// crossfades in a row read as a flicker). + /** * The backdrop scene for the song playing: the setting's, or one that suits * the song, from its beat map. A stable pick by the queue item breaks ties, @@ -13,12 +18,12 @@ export function useScene(): Scene { const { scene } = useStore(beatSettings) const { np, map, mapKey } = useStore(beatSource) if (scene !== 'auto') return scene as Scene - const key = np?.itemId ?? np?.track.trackId + const key = mapKey ?? np?.itemId ?? np?.track.trackId if (!key) return 'mesh' let h = 0 for (const ch of key) h = (h * 31 + ch.charCodeAt(0)) | 0 const pick = (...from: Scene[]) => from[Math.abs(h) % from.length] - if (!map || mapKey !== np?.itemId) return pick(...SCENES) + if (!map) return pick(...SCENES) const { energy, brightness } = map.features // No steady beat (ambient, a drone, spoken word): something that flows. if (map.bpm === 0) return pick('mesh', 'aurora') @@ -44,12 +49,12 @@ export function isShow(s: string): s is Show { export function useShow(setting: string): Show { const { np, map, mapKey } = useStore(beatSource) if (isShow(setting)) return setting - const key = np?.itemId ?? np?.track.trackId + const key = mapKey ?? np?.itemId ?? np?.track.trackId if (!key) return 'fluid' let h = 7 for (const ch of key) h = (h * 33 + ch.charCodeAt(0)) | 0 const pick = (...from: Show[]) => from[Math.abs(h) % from.length] - if (!map || mapKey !== np?.itemId) return pick('spectrum', 'fluid', 'tunnel') + if (!map) return pick('spectrum', 'fluid', 'tunnel') const { energy } = map.features if (map.bpm === 0) return pick('fluid', 'aurora') if (energy < 0.4 || map.bpm < 90) return pick('fluid', 'aurora', 'rain') diff --git a/web/src/lib/beat.ts b/web/src/lib/beat.ts index 1557753..6ad7cf9 100644 --- a/web/src/lib/beat.ts +++ b/web/src/lib/beat.ts @@ -152,6 +152,12 @@ function mapFor(key: string) { return map && mapKey === key ? map : null } +/** Whether the song's map is still on its way (mapKey is set once it's known, map or not). */ +function mapPending(key: string) { + const { np, mapKey } = beatSource.get() + return !!np?.itemId && np.itemId === key && mapKey !== key +} + /** Where the current song's beat comes from, for the beat lab. */ export type BeatOrigin = 'tapped' | 'analysed' | 'steady' | 'default' @@ -295,6 +301,13 @@ const reduced = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced- // Smoothed state, eased every frame so nothing snaps. let presence = 0 +// How hard the beat drives things: only while the music actually plays. +let drive = 0 +// When the music stopped playing here. A song change passes through +// "loading" for a moment, sometimes more than once: the backdrop holds +// through that, rather than fading out and back in (it flickers). +let quietSince = 0 +const HOLD_MS = 1500 let energy = 0 let loud = 0 let swell = 0 @@ -357,6 +370,8 @@ function place(key: string, pos: number): { index: number; into: number; period: const own = tapped.get(key) const map = mapFor(key) if (!own && map) return map.bpm > 0 ? beatAt(map, pos) : null + // No guessing while the map is on its way: the guess would jump when it lands. + if (!own && mapPending(key)) return null const { bpm, anchorMs } = own ?? { bpm: DEFAULT_BPM, anchorMs: 0 } const period = 60000 / bpm const since = pos - anchorMs @@ -378,7 +393,11 @@ function tick(now: number) { const map = c ? mapFor(c.key) : null const intensity = beatSettings.get().intensity // Fades in over about a second when the music starts, out when it stops. - presence = approach(presence, playing ? 1 : 0, dt, playing ? 900 : 500) + if (playing) quietSince = 0 + else if (!quietSince) quietSince = now + const held = playing || (quietSince > 0 && now - quietSince < HOLD_MS && !!c) + presence = approach(presence, held ? 1 : 0, dt, held ? 900 : 700) + drive = approach(drive, playing ? 1 : 0, dt, playing ? 400 : 200) // Energy: the section's, nudged by how loud it is right now. let target = 0.5 @@ -394,7 +413,7 @@ function tick(now: number) { let breath = 0 const p = c && presence > 0.001 ? place(c.key, c.pos) : null // Louder songs hit harder, but even a quiet one keeps a faint pulse. - const gain = presence * (0.35 + 0.65 * energy) * intensity + const gain = presence * drive * (0.35 + 0.65 * energy) * intensity if (p) { beatIndex = p.index periodMs = p.period @@ -426,7 +445,7 @@ function tick(now: number) { // so the bars move rather than sit high. for (let k = 0; k < BANDS; k++) { const v = Math.max(0, (spectrum[k] - 0.4) / 0.6) ** 1.3 - level[k] = follow(level[k], v * presence, dt, 40, 220) + level[k] = follow(level[k], v * presence * drive, dt, 40, 220) } const group = (from: number) => ((level[from] + level[from + 1] + level[from + 2]) / 3) * Math.min(1.5, intensity) vars.b0 = group(0) @@ -434,7 +453,7 @@ function tick(now: number) { vars.b2 = group(6) vars.b3 = group(9) - swell = approach(swell, breath * presence * (0.45 + 0.55 * energy) * intensity, dt, 160) + swell = approach(swell, breath * presence * drive * (0.45 + 0.55 * energy) * intensity, dt, 160) vars.swell = swell // Calm songs drift slowly, loud ones churn. Only now and then: changing @@ -471,7 +490,9 @@ function tick(now: number) { } // Idle once everything has settled: nothing playing, nothing fading. - if (playing || presence > 0.001 || swell > 0.001 || Math.abs(energy - target) > 0.001) frame = requestAnimationFrame(tick) + if (playing || presence > 0.001 || drive > 0.001 || swell > 0.001 || Math.abs(energy - target) > 0.001) { + frame = requestAnimationFrame(tick) + } else { for (const t of targets) if (t.drift) for (const a of t.el.getAnimations({ subtree: true })) a.updatePlaybackRate(1) lastRate = 1 From 3b2b81eb09e179f9ec22e926390b2a84e4bf53a4 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Wed, 7 Oct 2026 22:13:56 -0500 Subject: [PATCH 08/15] Fewer big blurred layers when the song changes A whole-screen black flash on some song changes looks like the browser dropping layers under GPU pressure: two full-screen backdrops (the app's, still composited behind now playing, and now playing's own), each crossfading a hugely blurred album cover, and, when the scene changed too, a second blurred canvas mounting for the crossfade. Scenes now crossfade on the one canvas they already have, and the app's backdrop stops rendering once now playing has fully covered it, coming back as soon as it starts to close. Co-Authored-By: Claude Opus 5.5 --- web/src/components/shell/app-shell.tsx | 18 +++++-- web/src/components/shell/backdrop-scene.tsx | 58 ++++++++++++--------- 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx index 7e0f169..67f4294 100644 --- a/web/src/components/shell/app-shell.tsx +++ b/web/src/components/shell/app-shell.tsx @@ -1,7 +1,7 @@ import { Outlet, useRouterState } from '@tanstack/react-router' import { WifiOff } from 'lucide-react' import { AnimatePresence, LayoutGroup, motion } from 'motion/react' -import { useState, type ReactNode } from 'react' +import { useEffect, useState, type ReactNode } from 'react' import { useAlbumPalette } from '@/hooks/use-album-palette' import { useBeatSync } from '@/hooks/use-beat-sync' import { easeOutExpo } from '@/lib/motion' @@ -23,14 +23,26 @@ import { NowPlayingSheet } from './now-playing-sheet' export function AppShell() { const { nowPlaying } = usePlayer() const [expanded, setExpanded] = useState(false) + // Once now playing has slid all the way up it covers everything, so the + // backdrop behind it stops rendering: two full-screen backdrops of big + // blurred layers, changing at once on a new song, made browsers flash + // black. It's back the moment now playing starts to close. + const [covered, setCovered] = useState(false) + useEffect(() => { + if (!expanded) return + const t = window.setTimeout(() => setCovered(true), 600) + return () => { + window.clearTimeout(t) + setCovered(false) + } + }, [expanded]) useAlbumPalette(nowPlaying) useBeatSync(nowPlaying) return (
- {/* Its scene rests while now playing covers it. */} - +
diff --git a/web/src/components/shell/backdrop-scene.tsx b/web/src/components/shell/backdrop-scene.tsx index aefd210..8a09e58 100644 --- a/web/src/components/shell/backdrop-scene.tsx +++ b/web/src/components/shell/backdrop-scene.tsx @@ -1,4 +1,3 @@ -import { AnimatePresence, motion } from 'motion/react' import { useEffect, useRef } from 'react' import { useScene } from '@/hooks/use-scene' import { beatSettings, onBeatFrame } from '@/lib/beat' @@ -14,29 +13,24 @@ import { cn } from '@/lib/utils' * not pixels, and a phone fills a quarter of the pixels a frame. */ -/** The scene layer, crossfading from one song's scene to the next. */ +/** + * The scene layer. One canvas for good: a new song's scene crossfades in + * on it, rather than on a second full-screen canvas (a pile of big + * blurred layers is what makes a browser flash black). + */ export function BackdropScene() { const scene = useScene() - return ( - - - - - - ) + return } +/** How long one painter takes to fade into the next. */ +const CROSSFADE_MS = 1600 + /** * A canvas a painter draws on every frame the beat engine runs. Drawn at a * fraction of the element's size (`scale`) and blurred by its class, so it - * reads as glow. In the app it follows the beat lab's scene strength. + * reads as glow. A new painter crossfades in on the same canvas. In the + * app it follows the beat lab's scene strength. */ export function SceneCanvas({ paint, scale = 1 / 2, app = true, className = 'opacity-70 blur-md dark:opacity-90' }: { paint: () => Painter @@ -46,12 +40,19 @@ export function SceneCanvas({ paint, scale = 1 / 2, app = true, className = 'opa className?: string }) { const ref = useRef(null) + // The painter drawing now, the one fading out, and since when. + const layers = useRef<{ make: () => Painter; draw: Painter; old?: Painter; since: number } | null>(null) + + useEffect(() => { + const l = layers.current + if (!l) layers.current = { make: paint, draw: paint(), since: -Infinity } + else if (l.make !== paint) layers.current = { make: paint, draw: paint(), old: l.draw, since: performance.now() } + }, [paint]) useEffect(() => { const canvas = ref.current const ctx = canvas?.getContext('2d') if (!canvas || !ctx) return - const draw = paint() const fit = () => { canvas.width = Math.max(1, Math.round(canvas.clientWidth * scale)) @@ -66,24 +67,33 @@ export function SceneCanvas({ paint, scale = 1 / 2, app = true, className = 'opa let readAt = 0 const off = onBeatFrame((f) => { + const l = layers.current if (f.now - readAt > 300) { colors = readColors(canvas) readAt = f.now } ctx.clearRect(0, 0, canvas.width, canvas.height) - if (f.presence < 0.002) return + if (!l || f.presence < 0.002) return const strength = app ? beatSettings.get().effects.scene : 1 if (strength === 0) return - ctx.save() - ctx.globalAlpha = Math.min(1, f.presence * strength) - draw(ctx, canvas.width, canvas.height, f, colors) - ctx.restore() + const t = Math.min(1, (f.now - l.since) / CROSSFADE_MS) + if (t >= 1) l.old = undefined + const layer = (draw: Painter, weight: number) => { + // Painters fade with presence, so scaling it fades the whole layer. + const presence = Math.min(1, f.presence * strength * weight) + ctx.save() + ctx.globalAlpha = presence + draw(ctx, canvas.width, canvas.height, { ...f, presence }, colors) + ctx.restore() + } + if (l.old) layer(l.old, 1 - t) + layer(l.draw, t) }) return () => { off() ro.disconnect() } - }, [paint, scale, app]) + }, [scale, app]) return } From 8e57df88e1a97dc967ac0e9b9cc785d7c2c5e412 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 8 Oct 2026 06:18:38 -0500 Subject: [PATCH 09/15] Lights mode: phones flash together on the downbeat (MAD-781) An opt-in full-screen wash of the song's palette that flashes on each downbeat. Every phone reads the same synced playback clock and beat map, so a room of them flashes together and changes color bar by bar. Flashes are once a bar, with the engine's capped beat pulse faint underneath; reduced motion or visuals off hold a steady glow. Opens from now playing's header and the beat lab; keeps the screen awake. The wake lock moves out of the TV stage into hooks/use-wake-lock.ts, and beat frames now carry the beat's place in its bar. Co-Authored-By: Claude Opus 5.5 --- web/src/components/shell/app-shell.tsx | 2 + web/src/components/shell/beat-lab.tsx | 12 +- .../components/shell/now-playing-sheet.tsx | 13 +- web/src/components/tv/tv-stage.tsx | 25 +-- web/src/components/visualizer/lights-mode.tsx | 142 ++++++++++++++++++ web/src/hooks/use-wake-lock.ts | 25 +++ web/src/lib/beat.ts | 8 + 7 files changed, 200 insertions(+), 27 deletions(-) create mode 100644 web/src/components/visualizer/lights-mode.tsx create mode 100644 web/src/hooks/use-wake-lock.ts diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx index 67f4294..7005fbc 100644 --- a/web/src/components/shell/app-shell.tsx +++ b/web/src/components/shell/app-shell.tsx @@ -8,6 +8,7 @@ import { easeOutExpo } from '@/lib/motion' import { usePlayer } from '@/lib/now-playing' import { useOnline } from '@/lib/pwa' import { Toaster } from '@/components/toaster' +import { LightsMode } from '@/components/visualizer/lights-mode' import { VisualizerMode } from '@/components/visualizer/visualizer-mode' import { AlbumBackdrop } from './album-backdrop' import { BeatLab } from './beat-lab' @@ -60,6 +61,7 @@ export function AppShell() { {!expanded && } +
) diff --git a/web/src/components/shell/beat-lab.tsx b/web/src/components/shell/beat-lab.tsx index cf9b39c..e14c24f 100644 --- a/web/src/components/shell/beat-lab.tsx +++ b/web/src/components/shell/beat-lab.tsx @@ -1,4 +1,4 @@ -import { Activity, RotateCcw, Sparkles, X } from 'lucide-react' +import { Activity, Lightbulb, RotateCcw, Sparkles, X } from 'lucide-react' import { Popover } from 'radix-ui' import { useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' @@ -15,6 +15,7 @@ import { calibrateTap, currentBeat, gridVersion, + lightsOpen, nudge, resetGrid, scaleTempo, @@ -159,6 +160,15 @@ function Look() {

The song's visuals, full screen. ← and → change the show.

+ + +

+ The screen flashes the song's colors on the downbeat, in time with every other phone in the room doing the same. +

+
) } diff --git a/web/src/components/shell/now-playing-sheet.tsx b/web/src/components/shell/now-playing-sheet.tsx index e83b28d..68e8750 100644 --- a/web/src/components/shell/now-playing-sheet.tsx +++ b/web/src/components/shell/now-playing-sheet.tsx @@ -1,4 +1,4 @@ -import { ChevronDown, ListMusic, Mic2, Sparkles } from 'lucide-react' +import { ChevronDown, Lightbulb, ListMusic, Mic2, Sparkles } from 'lucide-react' import { AnimatePresence, motion, useDragControls } from 'motion/react' import { Dialog } from 'radix-ui' import { useRef, useState } from 'react' @@ -14,7 +14,7 @@ import { UserAvatar } from '@/components/user-avatar' import { useBeat } from '@/hooks/use-beat' import { usePosition } from '@/hooks/use-position' import { laneStyle } from '@/lib/lane' -import { visualizerOpen } from '@/lib/beat' +import { lightsOpen, visualizerOpen } from '@/lib/beat' import { easeOutExpo, spring } from '@/lib/motion' import { formatDuration, usePlayer } from '@/lib/now-playing' import { AlbumBackdrop } from './album-backdrop' @@ -91,6 +91,15 @@ export function NowPlayingSheet({ open, onOpenChange }: { open: boolean; onOpenC > + +
+
+

Lights

+

+ {still + ? level === 'off' + ? 'Visuals are off, so the light holds steady.' + : 'Reduce motion is on, so the light holds steady.' + : np + ? 'Hold your phone up. Every phone in lights mode flashes on the same beat.' + : 'Nothing is playing yet.'} +

+
+
+ + ) +} diff --git a/web/src/hooks/use-wake-lock.ts b/web/src/hooks/use-wake-lock.ts new file mode 100644 index 0000000..280409a --- /dev/null +++ b/web/src/hooks/use-wake-lock.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react' + +/** Keeps the screen from sleeping while it's on show. */ +export function useWakeLock() { + useEffect(() => { + let lock: WakeLockSentinel | undefined + let stopped = false + const request = async () => { + if (document.visibilityState !== 'visible' || !('wakeLock' in navigator)) return + try { + lock = await navigator.wakeLock.request('screen') + if (stopped) void lock.release() + } catch { + // Not allowed (battery saver, or no user gesture yet): the screen may dim. + } + } + void request() + document.addEventListener('visibilitychange', request) + return () => { + stopped = true + document.removeEventListener('visibilitychange', request) + void lock?.release() + } + }, []) +} diff --git a/web/src/lib/beat.ts b/web/src/lib/beat.ts index 6ad7cf9..9640fd5 100644 --- a/web/src/lib/beat.ts +++ b/web/src/lib/beat.ts @@ -114,6 +114,9 @@ function applyEffects(s: BeatSettings) { /** Whether the full-screen visualizer is open in the app. */ export const visualizerOpen = createStore(false) +/** Whether lights mode (MAD-781) is on: the phone as a light, flashing on the downbeats. */ +export const lightsOpen = createStore(false) + /** Tempo for songs with no beat map yet. */ const DEFAULT_BPM = 120 @@ -334,6 +337,8 @@ export type BeatFrame = { presence: number /** Beats since the grid's anchor, and the beat's length in ms. */ beatIndex: number + /** Where the beat falls in its bar, 0 (the one) – 3. */ + bar: number periodMs: number /** Frame time (performance.now) and time since the last frame. */ now: number @@ -409,6 +414,7 @@ function tick(now: number) { const vars = { beat: 0, downbeat: 0, swell: 0, energy: energy * presence, b0: 0, b1: 0, b2: 0, b3: 0 } let beatIndex = 0 + let bar = 0 let periodMs = 500 let breath = 0 const p = c && presence > 0.001 ? place(c.key, c.pos) : null @@ -416,6 +422,7 @@ function tick(now: number) { const gain = presence * drive * (0.35 + 0.65 * energy) * intensity if (p) { beatIndex = p.index + bar = p.bar periodMs = p.period // Never more than three flashes a second: at very fast tempos, every other beat. const flash = p.period >= 333 || p.index % 2 === 0 @@ -476,6 +483,7 @@ function tick(now: number) { spectrum: level, presence, beatIndex, + bar, periodMs, now, dt, From d13556a3259476a8a1907b39a06f3010d63c3f18 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 8 Oct 2026 06:55:23 -0500 Subject: [PATCH 10/15] Waveform: scale each song against its own range, so loud songs show their shape Co-Authored-By: Claude Opus 5.5 --- web/src/components/shell/waveform.tsx | 21 ++++++------------- web/src/lib/beat-map.test.ts | 20 +++++++++++++++++- web/src/lib/beat-map.ts | 30 +++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 16 deletions(-) diff --git a/web/src/components/shell/waveform.tsx b/web/src/components/shell/waveform.tsx index 787090a..e4f5950 100644 --- a/web/src/components/shell/waveform.tsx +++ b/web/src/components/shell/waveform.tsx @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { beatSettings, beatSource } from '@/lib/beat' +import { waveform } from '@/lib/beat-map' import { useStore } from '@/lib/store' import { cn } from '@/lib/utils' @@ -7,7 +8,7 @@ const BARS = 72 /** * The song's shape above the seek bar (MAD-777): its loudness from the beat - * map, filled in the album color up to where it's playing. A flat line + * map, relative to the song's own range (lib/beat-map.ts `waveform`), filled in the album color up to where it's playing. A flat line * until the map arrives (it keeps its space, so nothing jumps), and * nothing if the beat lab turns it off. */ @@ -21,20 +22,10 @@ export function Waveform({ itemId, positionMs, durationMs, className }: { const { level, effects } = useStore(beatSettings) const ours = map && mapKey === itemId ? map : null - const heights = useMemo(() => { - if (!ours || ours.loudness.length === 0) return Array(BARS).fill(0.12) - const per = ours.loudness.length / BARS - const out: number[] = [] - for (let b = 0; b < BARS; b++) { - let peak = 0 - for (let i = Math.floor(b * per); i < Math.min(ours.loudness.length, Math.floor((b + 1) * per)); i++) { - peak = Math.max(peak, ours.loudness[i]) - } - // Quiet parts still show a sliver, so the line reads as a whole. - out.push(0.12 + 0.88 * (peak / 255) ** 1.6) - } - return out - }, [ours]) + const heights = useMemo( + () => (ours && ours.loudness.length > 0 ? waveform(ours.loudness, BARS) : Array(BARS).fill(0.12)), + [ours], + ) if (level === 'off' || effects.waveform === 0) return null const played = durationMs > 0 ? Math.min(1, positionMs / durationMs) : 0 diff --git a/web/src/lib/beat-map.test.ts b/web/src/lib/beat-map.test.ts index 25da49d..5ba6229 100644 --- a/web/src/lib/beat-map.test.ts +++ b/web/src/lib/beat-map.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { beatAt, decode, loudnessAt, sectionEnergy, spectrumAt } from './beat-map' +import { beatAt, decode, loudnessAt, sectionEnergy, spectrumAt, waveform } from './beat-map' const wire = { durationMs: 10_000, @@ -49,3 +49,21 @@ describe('beat maps', () => { expect([...spectrumAt(m, -10, out)]).toEqual([0, 0]) }) }) + +describe('waveform', () => { + it('shows the shape of a loud, compressed song instead of filling to the top', () => { + // Verses at 220, choruses at 245: nearly flat out the whole way. + const loud = Uint8Array.from({ length: 400 }, (_, i) => (Math.floor(i / 100) % 2 ? 245 : 220)) + const h = waveform(loud, 4) + expect(Math.max(...h)).toBeCloseTo(0.92) + expect(h[0]).toBeLessThan(0.5) + expect(h[1]).toBeGreaterThan(0.9) + }) + + it('keeps a near-flat song flat, at mid-height', () => { + const flat = Uint8Array.from({ length: 400 }, (_, i) => 240 + Math.floor(i / 100)) + const h = waveform(flat, 8) + expect(Math.max(...h) - Math.min(...h)).toBeLessThan(0.15) + expect(Math.max(...h)).toBeLessThan(0.6) + }) +}) diff --git a/web/src/lib/beat-map.ts b/web/src/lib/beat-map.ts index d1293e3..9d0ff0a 100644 --- a/web/src/lib/beat-map.ts +++ b/web/src/lib/beat-map.ts @@ -104,3 +104,33 @@ export function loudnessAt(m: BeatMap, pos: number) { const i = Math.floor((pos / 1000) * m.frameRate) return i >= 0 && i < m.loudness.length ? m.loudness[i] / 255 : 0 } + +/** + * The song's shape for the waveform: `bars` heights, 0–1. Each bar is the + * average level of its stretch, scaled against the song's own quiet and + * loud parts, so a loud, compressed song still shows its verses and + * choruses instead of sitting at the top. A song that barely changes sits + * mid-height, so small wobbles don't become big ones. + */ +export function waveform(loudness: Uint8Array, bars: number): number[] { + const per = loudness.length / bars + const means: number[] = [] + for (let b = 0; b < bars; b++) { + const from = Math.floor(b * per) + const to = Math.max(from + 1, Math.min(loudness.length, Math.floor((b + 1) * per))) + let sum = 0 + for (let i = from; i < to; i++) sum += loudness[i] ?? 0 + means.push(sum / (to - from) / 255) + } + const sorted = [...means].sort((a, b) => a - b) + let hi = sorted[sorted.length - 1] ?? 0 + let lo = sorted[Math.floor(sorted.length * 0.1)] ?? 0 + if (hi - lo < 0.08) { + // Barely changes: sit in the middle, wobbling a little. + const mid = (hi + lo) / 2 + lo = mid - 0.04 + hi = mid + 0.04 + } + // Quiet parts still show a sliver, and only the loudest bar reaches the top. + return means.map((m) => 0.12 + 0.8 * Math.min(1, Math.max(0, (m - lo) / (hi - lo))) ** 1.5) +} From 69b15a3f898031c9b972926701b842305d3e0316 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 8 Oct 2026 09:06:29 -0500 Subject: [PATCH 11/15] Beat lab: scrolls inside now playing; the all-off switch says what it does Co-Authored-By: Claude Opus 5.5 --- web/src/components/shell/beat-lab.tsx | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/web/src/components/shell/beat-lab.tsx b/web/src/components/shell/beat-lab.tsx index e14c24f..3707105 100644 --- a/web/src/components/shell/beat-lab.tsx +++ b/web/src/components/shell/beat-lab.tsx @@ -1,6 +1,6 @@ import { Activity, Lightbulb, RotateCcw, Sparkles, X } from 'lucide-react' import { Popover } from 'radix-ui' -import { useState, type ReactNode } from 'react' +import { useCallback, useState, type ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Slider } from '@/components/ui/slider' import { Switch } from '@/components/ui/switch' @@ -41,14 +41,24 @@ type Tab = 'look' | 'effects' | 'beat' */ export function BeatLab({ inline = false }: { inline?: boolean }) { const [open, setOpen] = useState(false) + // Inside now playing (a modal dialog) the panel has to open within the + // dialog: the dialog blocks scrolling everywhere outside it. + const [container, setContainer] = useState(null) const beat = useBeat() + const trigger = useCallback( + (el: HTMLButtonElement | null) => { + setContainer(el?.closest('[role="dialog"]') ?? null) + return beat(el) + }, + [beat], + ) const { level } = useStore(beatSettings) return ( - + +
-

Beat lab

+
+

Beat lab

+

{on ? 'Visuals move with the music' : 'All visuals off'}

+
setBeatSettings({ level: v ? 'subtle' : 'off' })} />
{!on ? ( From f05dfe04521dd39b08a62a35bd8991da2e8980e7 Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 8 Oct 2026 09:25:26 -0500 Subject: [PATCH 12/15] Never get stuck with songs waiting; play now from the queue A speaker that went quiet made three songs time out, which stopped the room, and a stopped room had no play button. Now a song that never starts on a speaker that said nothing about it pauses instead of skipping (the speaker acknowledges each song it loads), and the room shows a play button whenever it's stopped with songs waiting. Pressing play with no speaker makes this device the speaker and starts the room in one tap; "Play on this phone" starts a paused room too. Tap a queued song's artwork to play it now. It takes the skip permission; in a room that votes on skips, you ask the room and everyone gets a card to agree, passing on the same share as a skip vote. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 39 ++- docs/adr/0003-playback-engine.md | 11 +- server/internal/httpapi/api.gen.go | 58 ++++- server/internal/httpapi/playback.go | 5 +- server/internal/playback/playback.go | 237 +++++++++++++++++-- server/internal/playback/playback_test.go | 166 ++++++++++++- server/internal/rooms/rooms.go | 17 ++ web/src/api/schema.gen.ts | 32 ++- web/src/components/room-live.tsx | 2 +- web/src/components/room/play-now-request.tsx | 93 ++++++++ web/src/components/room/queue-row.tsx | 63 ++++- web/src/components/room/speaker-panel.tsx | 28 ++- web/src/components/room/up-next.tsx | 7 + web/src/components/shell/app-shell.tsx | 2 + web/src/hooks/use-play-now.ts | 52 ++++ web/src/hooks/use-room-controls.ts | 39 ++- web/src/lib/speaker.ts | 12 +- web/src/routes/_app/_authed/room.tsx | 14 +- 18 files changed, 811 insertions(+), 66 deletions(-) create mode 100644 web/src/components/room/play-now-request.tsx create mode 100644 web/src/hooks/use-play-now.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index ccba01c..0df5241 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -5380,6 +5380,34 @@ components: $ref: "#/components/schemas/QueueItem" skipVotes: $ref: "#/components/schemas/SkipVotes" + playNow: + $ref: "#/components/schemas/PlayNowVote" + + PlayNowVote: + type: object + description: | + Someone's request to play a queued song now, in a room whose skip + permission is `vote`. It passes on the same share of the room as a + skip, and lapses at `expires`, when the song leaves the queue, or + when whoever asked takes it back. + required: [itemId, by, voters, needed, expires] + properties: + itemId: + type: string + by: + type: string + description: Who asked. Their vote is the first. + voters: + type: array + description: IDs of the users who agreed, in order. + items: + type: string + needed: + type: integer + description: How many agreeing play the song. + expires: + type: string + format: date-time SkipVotes: type: object @@ -5426,7 +5454,13 @@ components: properties: action: type: string - enum: [play, pause, skip, seek, vote_skip, unvote_skip] + enum: [play, pause, skip, seek, vote_skip, unvote_skip, play_now, vote_play_now, unvote_play_now] + description: | + `play_now` plays the queued song `itemId` straight away, skipping + the one playing. It takes the skip permission; in a room that + votes on skips, it asks the room instead (`playNow`), and + `vote_play_now` agrees. `unvote_play_now` takes a vote back, or + withdraws the request from whoever asked. positionMs: type: integer format: int64 @@ -5436,7 +5470,8 @@ components: type: string description: | Skip (or vote) only if this is still the current song, so two - people tapping skip skip one song. + people tapping skip skip one song. For `play_now` and its votes, + the queued song to play. ClaimPlayerRequest: type: object diff --git a/docs/adr/0003-playback-engine.md b/docs/adr/0003-playback-engine.md index 4a16693..dae14a0 100644 --- a/docs/adr/0003-playback-engine.md +++ b/docs/adr/0003-playback-engine.md @@ -43,9 +43,10 @@ The song's service decides the driver, by whether its session implements `provid | --- | --- | | The song can't start (link removed, service error, speaker reports `error`) | Skip it and push a `playback.notice` | | A streamed song doesn't start within 20s | Skip it with a notice | +| …and the speaker said nothing about it the whole time (its tab is asleep, or gone) | Pause on it with a notice instead. The song isn't to blame, and skipping would burn the queue. The speaker acknowledges each song it starts loading (a `progress` report), so a speaker that's there but can't play the song still skips it. Play loads it again | | The speaker never reports `ended` | Move on 15s after the song's length | | The speaker goes quiet for 2 minutes while playing | Pause with a notice. The timeout is generous because mobile browsers throttle background tabs | -| 3 songs in a row fail | Stop, with a notice, instead of skipping through the whole queue. Pressing play resumes | +| 3 songs in a row fail | Stop, with a notice, instead of skipping through the whole queue. Pressing play resumes; the room shows a play button while it's stopped | ### Permissions @@ -57,6 +58,14 @@ When `skip` is `vote`, the owner and the song's requester can still skip outrigh The vote passes once more than `skipVotePercent` (default 50, a majority) of the room has voted. "The room" is everyone connected to it, plus anyone who voted and has since left, minus whoever queued the song, since they'd just skip it. The engine re-counts whenever someone joins or leaves and whenever the owner changes the settings, so a vote can pass because a member walked away. A passed vote skips with a `playback.notice`. +#### Play now + +`play_now` with a queued `itemId` makes that song current straight away, skipping the one playing. The fair order isn't changed: the song simply jumps it, and the fair order then sees its requester just played. It takes the `skip` permission. When `skip` is `vote`, the owner still plays it outright, and anyone else asks the room: `nowplaying.updated` carries the request (`playNow`), everyone gets a notice, and the room agrees with `vote_play_now` on the same share as a skip vote. One request is open at a time. It lapses after 2 minutes, when its song leaves the queue, or when whoever asked withdraws it (`unvote_play_now`). + +#### One tap to play + +With no speaker, a member who may be the speaker pressing play becomes the speaker and starts the room in the same tap. "Play on this phone" does the same for a paused room. + ### Restarts Playback state lives in memory. After a restart, a song that was playing comes back **paused at its start**: nobody knows how far the old speaker got. The stored player device is cleared, and the speaker claims the room again when it reconnects. diff --git a/server/internal/httpapi/api.gen.go b/server/internal/httpapi/api.gen.go index e484ae6..feaa29a 100644 --- a/server/internal/httpapi/api.gen.go +++ b/server/internal/httpapi/api.gen.go @@ -242,12 +242,15 @@ func (e PermissionLevel) Valid() bool { // Defines values for PlaybackCommandAction. const ( - Pause PlaybackCommandAction = "pause" - Play PlaybackCommandAction = "play" - Seek PlaybackCommandAction = "seek" - Skip PlaybackCommandAction = "skip" - UnvoteSkip PlaybackCommandAction = "unvote_skip" - VoteSkip PlaybackCommandAction = "vote_skip" + Pause PlaybackCommandAction = "pause" + Play PlaybackCommandAction = "play" + PlayNow PlaybackCommandAction = "play_now" + Seek PlaybackCommandAction = "seek" + Skip PlaybackCommandAction = "skip" + UnvotePlayNow PlaybackCommandAction = "unvote_play_now" + UnvoteSkip PlaybackCommandAction = "unvote_skip" + VotePlayNow PlaybackCommandAction = "vote_play_now" + VoteSkip PlaybackCommandAction = "vote_skip" ) // Valid indicates whether the value is a known member of the PlaybackCommandAction enum. @@ -257,12 +260,18 @@ func (e PlaybackCommandAction) Valid() bool { return true case Play: return true + case PlayNow: + return true case Seek: return true case Skip: return true + case UnvotePlayNow: + return true case UnvoteSkip: return true + case VotePlayNow: + return true case VoteSkip: return true default: @@ -1568,6 +1577,12 @@ type NowPlaying struct { Item *QueueItem `json:"item,omitempty"` Next *QueueItem `json:"next,omitempty"` + // PlayNow Someone's request to play a queued song now, in a room whose skip + // permission is `vote`. It passes on the same share of the room as a + // skip, and lapses at `expires`, when the song leaves the queue, or + // when whoever asked takes it back. + PlayNow *PlayNowVote `json:"playNow,omitempty"` + // Player The device a room plays through. Player *Player `json:"player,omitempty"` PositionMs int64 `json:"positionMs"` @@ -1702,19 +1717,46 @@ type PersonStats struct { UserId string `json:"userId"` } +// PlayNowVote Someone's request to play a queued song now, in a room whose skip +// permission is `vote`. It passes on the same share of the room as a +// skip, and lapses at `expires`, when the song leaves the queue, or +// when whoever asked takes it back. +type PlayNowVote struct { + // By Who asked. Their vote is the first. + By string `json:"by"` + Expires time.Time `json:"expires"` + ItemId string `json:"itemId"` + + // Needed How many agreeing play the song. + Needed int `json:"needed"` + + // Voters IDs of the users who agreed, in order. + Voters []string `json:"voters"` +} + // PlaybackCommand defines model for PlaybackCommand. type PlaybackCommand struct { + // Action `play_now` plays the queued song `itemId` straight away, skipping + // the one playing. It takes the skip permission; in a room that + // votes on skips, it asks the room instead (`playNow`), and + // `vote_play_now` agrees. `unvote_play_now` takes a vote back, or + // withdraws the request from whoever asked. Action PlaybackCommandAction `json:"action"` // ItemId Skip (or vote) only if this is still the current song, so two - // people tapping skip skip one song. + // people tapping skip skip one song. For `play_now` and its votes, + // the queued song to play. ItemId *string `json:"itemId,omitempty"` // PositionMs Where to seek to. PositionMs *int64 `json:"positionMs,omitempty"` } -// PlaybackCommandAction defines model for PlaybackCommand.Action. +// PlaybackCommandAction `play_now` plays the queued song `itemId` straight away, skipping +// the one playing. It takes the skip permission; in a room that +// votes on skips, it asks the room instead (`playNow`), and +// `vote_play_now` agrees. `unvote_play_now` takes a vote back, or +// withdraws the request from whoever asked. type PlaybackCommandAction string // PlaybackNotice Something members should hear about, like a song skipped because its service failed. diff --git a/server/internal/httpapi/playback.go b/server/internal/httpapi/playback.go index 383a586..f14fc7f 100644 --- a/server/internal/httpapi/playback.go +++ b/server/internal/httpapi/playback.go @@ -26,7 +26,7 @@ func (s *Server) GetPlayback(ctx context.Context, req GetPlaybackRequestObject) return GetPlayback200JSONResponse(toNowPlaying(np)), nil } -// ControlPlayback plays, pauses, skips or seeks. +// ControlPlayback plays, pauses, skips, seeks, or plays a queued song now. func (s *Server) ControlPlayback(ctx context.Context, req ControlPlaybackRequestObject) (ControlPlaybackResponseObject, error) { c := playback.Command{Action: string(req.Body.Action)} if req.Body.PositionMs != nil { @@ -191,5 +191,8 @@ func toNowPlaying(np rooms.NowPlaying) NowPlaying { if v := np.SkipVotes; v != nil { out.SkipVotes = &SkipVotes{Voters: v.Voters, Needed: v.Needed} } + if v := np.PlayNow; v != nil { + out.PlayNow = &PlayNowVote{ItemId: v.ItemID, By: v.By, Voters: v.Voters, Needed: v.Needed, Expires: v.Expires} + } return out } diff --git a/server/internal/playback/playback.go b/server/internal/playback/playback.go index 2d6d0be..031c64a 100644 --- a/server/internal/playback/playback.go +++ b/server/internal/playback/playback.go @@ -214,6 +214,13 @@ type room struct { halted bool // since is when the current state began (for the load timeout). since time.Time + // heard is whether the speaker has said anything about the current + // song since it began loading. A song that never starts on a silent + // speaker isn't the song's fault. + heard bool + // request is someone's request to play a queued song now, while the + // room votes on it. Needed is worked out when it's shown. + request *rooms.PlayNowVote // The remote driver's open session, while a remote song is current. sess provider.Session remote provider.Remote @@ -350,6 +357,7 @@ func (r *room) view(now time.Time) rooms.NowPlaying { func (e *Engine) view(r *room) rooms.NowPlaying { np := r.view(e.cfg.Now()) np.SkipVotes = e.tally(r) + np.PlayNow = e.playNowTally(r) return np } @@ -400,7 +408,7 @@ func (e *Engine) Claim(ctx context.Context, roomID, userID, deviceID, name strin r.np.Position, r.np.At = r.position(now), now r.np.Revision++ if r.np.State == StatePlaying && r.np.Driver == string(provider.PlaybackStream) { - r.np.State, r.since = StateLoading, now + r.np.State, r.since, r.heard = StateLoading, now, false } } if r.np.State == StateIdle && !r.halted { @@ -483,8 +491,19 @@ const ( // skip the current song, in rooms that vote on skips. ActionVoteSkip = "vote_skip" ActionUnvoteSkip = "unvote_skip" + // ActionPlayNow plays a queued song straight away, skipping the one + // playing. In a room that votes on skips, it asks the room instead; + // ActionVotePlayNow agrees, and ActionUnvotePlayNow takes it back (or + // withdraws the request, from whoever asked). + ActionPlayNow = "play_now" + ActionVotePlayNow = "vote_play_now" + ActionUnvotePlayNow = "unvote_play_now" ) +// PlayNowTimeout is how long a request to play a song now waits for the +// room to agree. +const PlayNowTimeout = 2 * time.Minute + // Command is a member's request to control playback. type Command struct { Action string @@ -492,6 +511,7 @@ type Command struct { Position time.Duration // ItemID, if set, makes a skip or vote apply only while that item is // current, so two people tapping skip at once skip one song, not two. + // For play_now and its votes, it's the queued song to play. ItemID string } @@ -519,6 +539,12 @@ func (e *Engine) Command(ctx context.Context, roomID, userID string, c Command) level = perms.Seek case ActionSkip: level = perms.Skip + case ActionPlayNow: + // Playing a song now skips the one playing, so it's the skip + // permission's call; a room that votes on skips votes on this too. + if perms.Skip != rooms.Vote { + level = perms.Skip + } } ownSkip := c.Action == ActionSkip && mine if level != "" && !ownSkip && !rooms.Allowed(level, row.OwnerID, userID) { @@ -527,7 +553,7 @@ func (e *Engine) Command(ctx context.Context, roomID, userID string, c Command) // Guests may skip their own songs, and vote if the room lets them; // the rest of the controls are the members'. if _, err := e.db.GetGuest(ctx, userID); err == nil { - vote := c.Action == ActionVoteSkip || c.Action == ActionUnvoteSkip + vote := c.Action == ActionVoteSkip || c.Action == ActionUnvoteSkip || c.Action == ActionVotePlayNow || c.Action == ActionUnvotePlayNow if !ownSkip && (!vote || !r.settings.Guests.CanVote()) { return rooms.NowPlaying{}, ErrForbidden } @@ -591,8 +617,27 @@ func (e *Engine) Command(ctx context.Context, roomID, userID string, c Command) r.votes = slices.DeleteFunc(r.votes, func(id string) bool { return id == userID }) e.publish(r) } + case ActionPlayNow: + err = e.playNowCommand(ctx, r, userID, c.ItemID) + case ActionVotePlayNow, ActionUnvotePlayNow: + q := r.request + if q == nil || (c.ItemID != "" && c.ItemID != q.ItemID) { + break // the request is over + } + voted := slices.Contains(q.Voters, userID) + switch { + case c.Action == ActionUnvotePlayNow && userID == q.By: + r.request = nil + e.publish(r) + case c.Action == ActionVotePlayNow && !voted: + q.Voters = append(q.Voters, userID) + err = e.settlePlayNow(ctx, r) + case c.Action == ActionUnvotePlayNow && voted: + q.Voters = slices.DeleteFunc(q.Voters, func(id string) bool { return id == userID }) + e.publish(r) + } default: - return rooms.NowPlaying{}, &InvalidInputError{"action is play, pause, skip, seek, vote_skip or unvote_skip"} + return rooms.NowPlaying{}, &InvalidInputError{"action is play, pause, skip, seek, vote_skip, unvote_skip, play_now, vote_play_now or unvote_play_now"} } if err != nil { return rooms.NowPlaying{}, err @@ -600,6 +645,58 @@ func (e *Engine) Command(ctx context.Context, roomID, userID string, c Command) return e.view(r), nil } +// playNowCommand plays a queued song now for userID, who passed the +// permission check, or asks the room if it votes on skips and they can't +// skip outright. +func (e *Engine) playNowCommand(ctx context.Context, r *room, userID, itemID string) error { + if itemID == "" { + return &InvalidInputError{"play_now needs the itemId of a queued song"} + } + it, err := e.queue.Item(ctx, r.id, itemID) + if errors.Is(err, queue.ErrNotFound) || (err == nil && it.State != store.ItemQueued) { + return &InvalidInputError{"that song isn't waiting in the queue"} + } else if err != nil { + return err + } + if r.np.Player == nil { + return ErrNoPlayer + } + // The owner doesn't need to ask. + if r.settings.Permissions.Skip != rooms.Vote || userID == r.owner { + return e.playNow(ctx, r, itemID) + } + if q := r.request; q != nil { + if q.ItemID == itemID { + if !slices.Contains(q.Voters, userID) { + q.Voters = append(q.Voters, userID) + } + return e.settlePlayNow(ctx, r) + } + if q.By != userID { + return &InvalidInputError{"someone's already asking to play a song now; wait for the room to decide"} + } + } + r.request = &rooms.PlayNowVote{ItemID: itemID, By: userID, Voters: []string{userID}, Expires: e.cfg.Now().Add(PlayNowTimeout)} + if v := e.playNowTally(r); v != nil && len(v.Voters) < v.Needed { + name := "Someone" + if u, err := e.db.GetUser(ctx, userID); err == nil { + name = u.DisplayName + } + e.notice(r, itemID, fmt.Sprintf("%s wants to play %s now", name, title(&it))) + } + return e.settlePlayNow(ctx, r) +} + +// playNow ends the current song, if any, and starts itemID in its place. +func (e *Engine) playNow(ctx context.Context, r *room, itemID string) error { + r.halted, r.errors = false, 0 + reason := "" + if r.np.Item != nil { + reason = store.EndSkipped + } + return e.nextItem(ctx, r, reason, itemID) +} + func (e *Engine) play(ctx context.Context, r *room) error { if r.np.Player == nil { return ErrNoPlayer @@ -625,6 +722,10 @@ func (e *Engine) play(ctx context.Context, r *room) error { } } r.np.State, r.np.At, r.since = StatePlaying, now, now + if r.np.Driver == string(provider.PlaybackStream) && !r.heard { + // The speaker never started it: wait for it to, under the load timeout. + r.np.State = StateLoading + } r.np.Revision++ e.publish(r) return nil @@ -681,6 +782,7 @@ func (e *Engine) Report(ctx context.Context, roomID, userID string, rep Report) if r.np.Item == nil || r.np.Item.ID != rep.ItemID || r.np.Driver != string(provider.PlaybackStream) { return e.view(r), nil } + r.heard = true switch rep.Event { case EventPlaying: if r.np.State == StateLoading || r.np.State == StatePlaying { @@ -731,14 +833,23 @@ func (e *Engine) Report(ctx context.Context, roomID, userID string, rep Report) // songs that fail to start. With no speaker, or after too many failures, // the room goes idle instead. The caller holds r.mu. func (e *Engine) next(ctx context.Context, r *room, reason string) error { + return e.nextItem(ctx, r, reason, "") +} + +// nextItem is next, starting want first if it's still queued. +func (e *Engine) nextItem(ctx context.Context, r *room, reason, want string) error { for { start := r.np.Player != nil && !r.halted - item, snap, err := e.rotate(ctx, r, reason, start) + item, snap, err := e.rotate(ctx, r, reason, start, want) if err != nil { return err } + want = "" now := e.cfg.Now() r.votes = nil + if r.request != nil && (item == nil || item.ID == r.request.ItemID || !queued(snap, r.request.ItemID)) { + r.request = nil + } r.np.Item, r.np.Position, r.np.At, r.since = item, 0, now, now r.np.Next = nextOf(snap) r.np.Revision++ @@ -774,9 +885,10 @@ func (e *Engine) countFailure(r *room, item *store.QueueItem, msg string, cause } // rotate ends the playing song with reason and, if start, makes the next -// song in fair order the playing one. It returns that song (nil if none) -// and the queue as it now stands. -func (e *Engine) rotate(ctx context.Context, r *room, reason string, start bool) (*store.QueueItem, rooms.QueueSnapshot, error) { +// song the playing one: want if it's queued, or else the next in fair +// order. It returns that song (nil if none) and the queue as it now +// stands. +func (e *Engine) rotate(ctx context.Context, r *room, reason string, start bool, want string) (*store.QueueItem, rooms.QueueSnapshot, error) { r.closeRemote() var next *store.QueueItem snap, err := e.queue.Change(ctx, r.id, func(q *store.Queries, _ store.Room) error { @@ -803,6 +915,9 @@ func (e *Engine) rotate(ctx context.Context, r *room, reason string, start bool) return err } it := nextOf(snap) + if i := slices.IndexFunc(snap.Items, func(it store.QueueItem) bool { return it.ID == want && it.State == store.ItemQueued }); i >= 0 { + it = &snap.Items[i] + } if it == nil { return nil } @@ -911,7 +1026,7 @@ func (e *Engine) start(ctx context.Context, r *room, it store.QueueItem) error { return errors.New("its service can't play songs") } r.np.Driver = string(provider.PlaybackStream) - r.np.State = StateLoading + r.np.State, r.heard = StateLoading, false return nil } @@ -951,9 +1066,12 @@ func (e *Engine) roomUpdated(row store.Room) { was := r.settings.Permissions.Skip r.setRoom(row) if r.settings.Permissions.Skip == rooms.Vote { + if err := e.settlePlayNow(ctx, r); err != nil { + return err + } return e.settleVote(ctx, r) } - r.votes = nil + r.votes, r.request = nil, nil if was == rooms.Vote { e.publish(r) } @@ -983,7 +1101,12 @@ func (e *Engine) roomDeleted(roomID string) { // MembersChanged follows someone joining or leaving a room: a skip vote // needs more or fewer votes, and may now pass. func (e *Engine) MembersChanged(roomID string) { - e.background(roomID, "reacting to members", e.settleVote) + e.background(roomID, "reacting to members", func(ctx context.Context, r *room) error { + if err := e.settlePlayNow(ctx, r); err != nil { + return err + } + return e.settleVote(ctx, r) + }) } // background runs fn on a room the engine has loaded, on another @@ -1021,20 +1144,41 @@ func (e *Engine) tally(r *room) *rooms.SkipVotes { if r.settings.Permissions.Skip != rooms.Vote || r.np.Item == nil { return nil } + exclude := r.np.Item.AddedBy + if r.np.Item.IsAutopilot() { + exclude = "" + } + votes, needed := e.count(r, r.votes, exclude) + return &rooms.SkipVotes{Voters: votes, Needed: needed} +} + +// playNowTally counts the vote on a request to play a song now, or is nil +// if there's none. Everyone in the room may vote. +func (e *Engine) playNowTally(r *room) *rooms.PlayNowVote { + q := r.request + if q == nil || r.settings.Permissions.Skip != rooms.Vote { + return nil + } + v := *q + v.Voters, v.Needed = e.count(r, q.Voters, "") + return &v +} + +// count sizes a room's vote: the votes that count, and how many it needs. +// Everyone connected may vote but exclude; votes count even after the +// voter leaves. +func (e *Engine) count(r *room, cast []string, exclude string) (votes []string, needed int) { voters := map[string]bool{} if e.cfg.Presence != nil { for _, id := range e.cfg.Presence.Members(r.id) { voters[id] = true } } - // Votes count even after the voter leaves. - for _, id := range r.votes { + for _, id := range cast { voters[id] = true } - if !r.np.Item.IsAutopilot() { - delete(voters, r.np.Item.AddedBy) - } - votes := append([]string{}, r.votes...) + delete(voters, exclude) + votes = append([]string{}, cast...) // In rooms where guests don't vote, they don't count toward the votes // needed either. if !r.settings.Guests.CanVote() { @@ -1047,10 +1191,7 @@ func (e *Engine) tally(r *room) *rooms.SkipVotes { } } } - return &rooms.SkipVotes{ - Voters: votes, - Needed: rooms.VotesNeeded(len(voters), *r.settings.SkipVotePercent), - } + return votes, rooms.VotesNeeded(len(voters), *r.settings.SkipVotePercent) } // settleVote skips the current song if enough of the room voted to, and @@ -1068,6 +1209,27 @@ func (e *Engine) settleVote(ctx context.Context, r *room) error { return e.next(ctx, r, store.EndSkipped) } +// settlePlayNow plays the requested song if enough of the room agreed, +// and otherwise publishes the tally. The caller holds r.mu. +func (e *Engine) settlePlayNow(ctx context.Context, r *room) error { + v := e.playNowTally(r) + if v == nil { + return nil + } + if len(v.Voters) < v.Needed || r.np.Player == nil { + e.publish(r) + return nil + } + r.request = nil + if err := e.playNow(ctx, r, v.ItemID); err != nil { + return err + } + if it := r.np.Item; len(v.Voters) > 1 && it != nil && it.ID == v.ItemID { + e.notice(r, it.ID, fmt.Sprintf("Playing %s: the room agreed", title(it))) + } + return nil +} + // refresh starts an idle room when songs arrive, and keeps the preloaded // next song current as the order changes. func (e *Engine) refresh(ctx context.Context, r *room) error { @@ -1079,13 +1241,22 @@ func (e *Engine) refresh(ctx context.Context, r *room) error { return err } next := nextOf(snap) - if idOf(next) != idOf(r.np.Next) { + gone := r.request != nil && !queued(snap, r.request.ItemID) + if gone { + r.request = nil + } + if idOf(next) != idOf(r.np.Next) || gone { r.np.Next = next e.publish(r) } return nil } +// queued reports whether itemID is waiting in snap. +func queued(snap rooms.QueueSnapshot, itemID string) bool { + return slices.Contains(snap.UpNext, itemID) +} + func idOf(it *store.QueueItem) string { if it == nil { return "" @@ -1113,17 +1284,35 @@ func (e *Engine) Tick(ctx context.Context) { } func (e *Engine) tick(ctx context.Context, r *room) error { - if !r.loaded || r.np.Item == nil { + if !r.loaded { return nil } now := e.cfg.Now() + if r.request != nil && !now.Before(r.request.Expires) { + r.request = nil + e.publish(r) + } + if r.np.Item == nil { + return nil + } switch r.np.Driver { case string(provider.PlaybackStream): switch r.np.State { case StateLoading: - if now.Sub(r.since) >= e.cfg.LoadTimeout { - return e.failed(ctx, r, fmt.Sprintf("Skipped %s: it took too long to start", title(r.np.Item)), errors.New("load timeout")) + if now.Sub(r.since) < e.cfg.LoadTimeout { + break + } + if p := r.np.Player; p != nil && !r.heard { + // The speaker never answered: it's asleep or gone, and + // skipping would burn the queue on songs that are fine. + if err := e.pause(ctx, r); err != nil { + return err + } + e.notice(r, r.np.Item.ID, fmt.Sprintf("Paused: %s didn't start %s", playerName(p), title(r.np.Item))) + e.publish(r) + return nil } + return e.failed(ctx, r, fmt.Sprintf("Skipped %s: it took too long to start", title(r.np.Item)), errors.New("load timeout")) case StatePlaying: if p := r.np.Player; p != nil && now.Sub(p.LastSeen) >= e.cfg.PlayerTimeout { if err := e.pause(ctx, r); err != nil { diff --git a/server/internal/playback/playback_test.go b/server/internal/playback/playback_test.go index 0898a2b..65310a3 100644 --- a/server/internal/playback/playback_test.go +++ b/server/internal/playback/playback_test.go @@ -431,7 +431,8 @@ func TestFailuresSkip(t *testing.T) { if !playing(playback.StateLoading, "t02")(np) { t.Fatalf("after error: %s", describe(np)) } - // It never starts: skipped after the load timeout. + // The speaker tries but it never starts: skipped after the load timeout. + e.report(e.alice, "phone", playback.EventProgress, 0) e.advance(11 * time.Second) e.p.Tick(t.Context()) e.waitFor("third song", playing(playback.StateLoading, "t03")) @@ -467,6 +468,166 @@ func TestFailuresSkip(t *testing.T) { } } +func TestSilentSpeaker(t *testing.T) { + e := newEnv(t) + notices := e.notices() + e.claim(e.alice, "phone") + e.add(e.alice, "t01", "t02", "t03", "t04") + e.waitFor("first song", playing(playback.StateLoading, "t01")) + + // The speaker never says a word about the song (its tab is asleep, or + // gone): the song isn't to blame, so the room pauses on it rather than + // skipping through the queue. + e.advance(11 * time.Second) + e.p.Tick(t.Context()) + np := e.np() + if !playing(playback.StatePaused, "t01")(np) || np.Position != 0 { + t.Fatalf("after a silent load: %s at %v", describe(np), np.Position) + } + if got := notices(); len(got) != 1 || !strings.Contains(got[0], "alice's phone didn't start “Reference Tone”") { + t.Fatalf("notices: %q", got) + } + // Play tries the same song again, waiting for the speaker to start it. + if np = e.must(playback.Command{Action: playback.ActionPlay}); !playing(playback.StateLoading, "t01")(np) { + t.Fatalf("play after a silent load: %s", describe(np)) + } + if np = e.report(e.alice, "phone", playback.EventPlaying, 0); !playing(playback.StatePlaying, "t01")(np) { + t.Fatalf("speaker woke up: %s", describe(np)) + } +} + +func TestPlayNow(t *testing.T) { + e := newEnv(t) + ctx := t.Context() + e.add(e.alice, "t01") + e.add(e.bob, "t04", "t05") + e.add(e.carol, "t07") + snap, _ := e.rooms.QueueSnapshot(ctx, e.room.ID) + id := func(track string) string { + for _, it := range snap.Items { + if it.TrackID == track { + return it.ID + } + } + t.Fatalf("no %s", track) + return "" + } + + if _, err := e.command(e.bob, playback.Command{Action: playback.ActionPlayNow, ItemID: id("t05")}); !errors.Is(err, playback.ErrNoPlayer) { + t.Fatalf("play now with no speaker: %v", err) + } + e.claim(e.alice, "phone") + e.report(e.alice, "phone", playback.EventPlaying, 0) + + // Bob's second song jumps everyone and the current song is skipped. + np, err := e.command(e.bob, playback.Command{Action: playback.ActionPlayNow, ItemID: id("t05")}) + if err != nil || !playing(playback.StateLoading, "t05")(np) { + t.Fatalf("play now: %s %v", describe(np), err) + } + h, _ := e.db.ListHistory(ctx, store.ListHistoryParams{RoomID: e.room.ID, Limit: 10}) + if len(h) != 2 || h[1].PlayHistory.EndReason.String != store.EndSkipped { + t.Fatalf("history: %+v", h) + } + // Only queued songs can be played now. + if _, err := e.command(e.bob, playback.Command{Action: playback.ActionPlayNow, ItemID: id("t01")}); err == nil { + t.Error("played a song that already played") + } + if _, err := e.command(e.bob, playback.Command{Action: playback.ActionPlayNow}); err == nil { + t.Error("play now with no item") + } + + // It takes the skip permission. + owner := rooms.Update{Permissions: rooms.Permissions{Skip: rooms.Owner}} + if _, err := e.rooms.Update(ctx, rooms.Actor{UserID: e.alice.ID}, e.room.ID, owner); err != nil { + t.Fatal(err) + } + if _, err := e.command(e.carol, playback.Command{Action: playback.ActionPlayNow, ItemID: id("t07")}); !errors.Is(err, playback.ErrForbidden) { + t.Errorf("carol in an owner-skips room: %v", err) + } + if np := e.must(playback.Command{Action: playback.ActionPlayNow, ItemID: id("t07")}); !playing(playback.StateLoading, "t07")(np) { + t.Errorf("the owner: %s", describe(np)) + } +} + +func TestPlayNowVote(t *testing.T) { + e := newEnv(t) + ctx := t.Context() + vote := rooms.Update{Permissions: rooms.Permissions{Skip: rooms.Vote}} + if _, err := e.rooms.Update(ctx, rooms.Actor{UserID: e.alice.ID}, e.room.ID, vote); err != nil { + t.Fatal(err) + } + for _, m := range []member{e.alice, e.bob, e.carol} { + e.presence.Join(e.room.ID, m.ID) + } + notices := e.notices() + e.claim(e.alice, "phone") + e.add(e.alice, "t01") + e.add(e.bob, "t04", "t05") + e.waitFor("alice's song", playing(playback.StateLoading, "t01")) + e.waitFor("bob's song to be next", func(np rooms.NowPlaying) bool { return np.Next != nil && np.Next.TrackID == "t04" }) + snap, _ := e.rooms.QueueSnapshot(ctx, e.room.ID) + var t05 string + for _, it := range snap.Items { + if it.TrackID == "t05" { + t05 = it.ID + } + } + playNow := playback.Command{Action: playback.ActionPlayNow, ItemID: t05} + + // Bob asks; the room has to agree (2 of 3). + np, err := e.command(e.bob, playNow) + if err != nil || !playing(playback.StateLoading, "t01")(np) { + t.Fatalf("asking: %s %v", describe(np), err) + } + if v := np.PlayNow; v == nil || v.ItemID != t05 || v.By != e.bob.ID || len(v.Voters) != 1 || v.Needed != 2 { + t.Fatalf("request: %+v", np.PlayNow) + } + if got := notices(); len(got) != 1 || got[0] != "bob wants to play “Pluge” now" { + t.Fatalf("notices: %q", got) + } + // Only one request at a time. + if _, err := e.command(e.carol, playback.Command{Action: playback.ActionPlayNow, ItemID: np.Next.ID}); err == nil { + t.Error("a second request while one is open") + } + // Carol agrees and agreeing passes it. + np, err = e.command(e.carol, playback.Command{Action: playback.ActionVotePlayNow, ItemID: t05}) + if err != nil || !playing(playback.StateLoading, "t05")(np) || np.PlayNow != nil { + t.Fatalf("after carol agreed: %s %+v %v", describe(np), np.PlayNow, err) + } + if got := notices(); len(got) != 2 || !strings.Contains(got[1], "the room agreed") { + t.Fatalf("notices: %q", got) + } + + // Asking again, then taking it back, ends the request. + e.add(e.carol, "t07") + snap, _ = e.rooms.QueueSnapshot(ctx, e.room.ID) + var t07 string + for _, it := range snap.Items { + if it.TrackID == "t07" { + t07 = it.ID + } + } + if np, _ = e.command(e.carol, playback.Command{Action: playback.ActionPlayNow, ItemID: t07}); np.PlayNow == nil { + t.Fatal("no request") + } + if np, _ = e.command(e.carol, playback.Command{Action: playback.ActionUnvotePlayNow, ItemID: t07}); np.PlayNow != nil { + t.Fatalf("withdrawn request still open: %+v", np.PlayNow) + } + // A request lapses. + if _, err := e.command(e.carol, playback.Command{Action: playback.ActionPlayNow, ItemID: t07}); err != nil { + t.Fatal(err) + } + e.advance(playback.PlayNowTimeout) + e.p.Tick(ctx) + if np = e.np(); np.PlayNow != nil { + t.Fatalf("lapsed request still open: %+v", np.PlayNow) + } + // The owner just plays it. + if np = e.must(playback.Command{Action: playback.ActionPlayNow, ItemID: t07}); !playing(playback.StateLoading, "t07")(np) { + t.Fatalf("the owner: %s", describe(np)) + } +} + func TestUnlinkedSongSkipped(t *testing.T) { e := newEnv(t) ctx := t.Context() @@ -571,8 +732,9 @@ func TestRestart(t *testing.T) { if r, _ := e.db.GetRoom(t.Context(), e.room.ID); r.PlayerDeviceID.Valid { t.Errorf("stale player kept: %+v", r.PlayerDeviceID) } + // The speaker hasn't started it since: play waits for it to. e.claim(e.alice, "phone") - if np, err := e.command(e.alice, playback.Command{Action: playback.ActionPlay}); err != nil || !playing(playback.StatePlaying, "t01")(np) { + if np, err := e.command(e.alice, playback.Command{Action: playback.ActionPlay}); err != nil || !playing(playback.StateLoading, "t01")(np) { t.Fatalf("resume: %s %v", describe(np), err) } } diff --git a/server/internal/rooms/rooms.go b/server/internal/rooms/rooms.go index e3f884a..fd40068 100644 --- a/server/internal/rooms/rooms.go +++ b/server/internal/rooms/rooms.go @@ -706,6 +706,23 @@ type NowPlaying struct { Next *store.QueueItem // SkipVotes is the vote to skip Item, while the room votes on skips. SkipVotes *SkipVotes + // PlayNow is someone's request to play a queued song now, while the + // room votes on it. + PlayNow *PlayNowVote +} + +// PlayNowVote is a request to play a queued song straight away, in a +// room that votes on skips. It passes on the same share of the room. +type PlayNowVote struct { + ItemID string + // By is who asked. Their vote is the first. + By string + // Voters are the IDs of users who agreed, in the order they did. + Voters []string + // Needed is how many agreeing play the song, given who's in the room. + Needed int + // Expires is when the request lapses if the room hasn't agreed. + Expires time.Time } // SkipVotes is a vote to skip the playing song. diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index 439f3c5..a6c6662 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -3618,6 +3618,24 @@ export interface components { player?: components["schemas"]["Player"]; next?: components["schemas"]["QueueItem"]; skipVotes?: components["schemas"]["SkipVotes"]; + playNow?: components["schemas"]["PlayNowVote"]; + }; + /** + * @description Someone's request to play a queued song now, in a room whose skip + * permission is `vote`. It passes on the same share of the room as a + * skip, and lapses at `expires`, when the song leaves the queue, or + * when whoever asked takes it back. + */ + PlayNowVote: { + itemId: string; + /** @description Who asked. Their vote is the first. */ + by: string; + /** @description IDs of the users who agreed, in order. */ + voters: string[]; + /** @description How many agreeing play the song. */ + needed: number; + /** Format: date-time */ + expires: string; }; /** * @description The vote to skip the playing song, while the room's skip permission @@ -3645,8 +3663,15 @@ export interface components { lastSeen: string; }; PlaybackCommand: { - /** @enum {string} */ - action: "play" | "pause" | "skip" | "seek" | "vote_skip" | "unvote_skip"; + /** + * @description `play_now` plays the queued song `itemId` straight away, skipping + * the one playing. It takes the skip permission; in a room that + * votes on skips, it asks the room instead (`playNow`), and + * `vote_play_now` agrees. `unvote_play_now` takes a vote back, or + * withdraws the request from whoever asked. + * @enum {string} + */ + action: "play" | "pause" | "skip" | "seek" | "vote_skip" | "unvote_skip" | "play_now" | "vote_play_now" | "unvote_play_now"; /** * Format: int64 * @description Where to seek to. @@ -3654,7 +3679,8 @@ export interface components { positionMs?: number; /** * @description Skip (or vote) only if this is still the current song, so two - * people tapping skip skip one song. + * people tapping skip skip one song. For `play_now` and its votes, + * the queued song to play. */ itemId?: string; }; diff --git a/web/src/components/room-live.tsx b/web/src/components/room-live.tsx index 9a201cf..6c51cd3 100644 --- a/web/src/components/room-live.tsx +++ b/web/src/components/room-live.tsx @@ -19,7 +19,7 @@ export function RoomLive() { useRoomSocket(room?.id) const playback = useQuery({ ...playbackQuery(room?.id ?? ''), enabled: !!room }) const users = useQuery(usersQuery) - const commands = useRoomControls(room, playback.data, me.id, !!me.guest) + const commands = useRoomControls(room, playback.data, me, !!me.guest) const queryClient = useQueryClient() // Player mode: whatever the server says, the speaker (if this device is diff --git a/web/src/components/room/play-now-request.tsx b/web/src/components/room/play-now-request.tsx new file mode 100644 index 0000000..caa8800 --- /dev/null +++ b/web/src/components/room/play-now-request.tsx @@ -0,0 +1,93 @@ +import { useQuery } from '@tanstack/react-query' +import { Check, X } from 'lucide-react' +import { AnimatePresence, motion } from 'motion/react' +import { useState } from 'react' +import { Artwork } from '@/components/artwork' +import { Button } from '@/components/ui/button' +import { usePlaybackCommand } from '@/hooks/use-play-now' +import { useMe } from '@/lib/auth' +import { spring } from '@/lib/motion' +import { playbackQuery, queueArtworkUrl } from '@/lib/playback' +import { queueQuery, useCurrentRoom } from '@/lib/room' +import { usersQuery } from '@/lib/users' + +/** + * Someone asked to play a song now, in a room that votes on skips: the + * rest of the room says yes here, on any screen. Whoever asked sees the + * tally and can take it back. + */ +export function PlayNowRequest() { + const me = useMe() + const { room } = useCurrentRoom() + const roomId = room?.id ?? '' + const playback = useQuery({ ...playbackQuery(roomId), enabled: false }) + const queue = useQuery({ ...queueQuery(roomId), enabled: false }) + const users = useQuery(usersQuery) + const command = usePlaybackCommand(roomId) + // "Not now" hides a request here; it lapses on its own. + const [hidden, setHidden] = useState() + + const req = room ? playback.data?.playNow : undefined + const item = req && queue.data?.items.find((i) => i.id === req.itemId) + const mine = req?.by === me.id + const voted = !!req?.voters.includes(me.id) + const mayVote = !me.guest || !!room?.guests.canVote + const show = req && item && mayVote && hidden !== `${req.itemId}:${req.by}` + const by = users.data?.find((u) => u.id === req?.by)?.displayName ?? 'Someone' + + return ( + + {show && ( + + +
+

+ {mine ? 'You asked to play ' : `${by} wants to play `} + {item.track.title} +

+

+ {req.voters.length} of {req.needed} agree +

+
+ {mine ? ( + + ) : ( + <> + + + + )} +
+ )} +
+ ) +} diff --git a/web/src/components/room/queue-row.tsx b/web/src/components/room/queue-row.tsx index 37c9daf..a7c8142 100644 --- a/web/src/components/room/queue-row.tsx +++ b/web/src/components/room/queue-row.tsx @@ -1,4 +1,5 @@ -import type { ReactNode } from 'react' +import { Play } from 'lucide-react' +import { useEffect, useRef, useState, type ReactNode } from 'react' import { Artwork } from '@/components/artwork' import { ServiceGlyph } from '@/components/service-tag' import { AutopilotMark } from '@/components/room/autopilot-badge' @@ -22,6 +23,8 @@ export function QueueRow({ mine, hideAvatar, byline, + onPlay, + playLabel = 'Play now', className, }: { roomId: string @@ -35,6 +38,9 @@ export function QueueRow({ hideAvatar?: boolean /** Name who added it under the title, for lists without lane context. */ byline?: boolean + /** Makes the artwork a play button: hover (or tap once on touch) shows it. */ + onPlay?: () => void + playLabel?: string className?: string }) { const autopilot = item.autopilot @@ -54,7 +60,13 @@ export function QueueRow({ )} > {leading} - + {onPlay ? ( + + + + ) : ( + + )}

{item.track.title}

@@ -82,3 +94,50 @@ export function QueueRow({

) } + +// How long a first tap on touch keeps the play button up. +const ARMED_FOR = 3000 + +/** + * Artwork that plays its song. With a mouse, hovering shows the button. On + * touch there's no hover, so the first tap shows it and a second plays: + * one stray tap shouldn't skip the song the room is listening to. + */ +function PlayArt({ label, onPlay, children }: { label: string; onPlay: () => void; children: ReactNode }) { + const [armed, setArmed] = useState(false) + const pointer = useRef('mouse') + useEffect(() => { + if (!armed) return + const t = window.setTimeout(() => setArmed(false), ARMED_FOR) + return () => window.clearTimeout(t) + }, [armed]) + return ( + + ) +} diff --git a/web/src/components/room/speaker-panel.tsx b/web/src/components/room/speaker-panel.tsx index 34c8c64..20b6625 100644 --- a/web/src/components/room/speaker-panel.tsx +++ b/web/src/components/room/speaker-panel.tsx @@ -5,9 +5,9 @@ import { Equalizer } from '@/components/equalizer' import { Switch } from '@/components/ui/switch' import { Button } from '@/components/ui/button' import { useMe } from '@/lib/auth' -import { can, playbackQuery } from '@/lib/playback' +import { can, playbackQuery, sendCommand } from '@/lib/playback' import type { Room } from '@/lib/room' -import { deviceId, speaker, speakerState } from '@/lib/speaker' +import { deviceId, speaker, speakerName, speakerState } from '@/lib/speaker' import { useStore } from '@/lib/store' import { cn } from '@/lib/utils' import { deviceName } from '@/lib/webauthn' @@ -29,11 +29,23 @@ export function SpeakerPanel({ room, prominent }: { room: Room; prominent?: bool // Guests are in the room with the speaker; listening along is for members. const mayListen = !me.guest const thisDevice = deviceNoun() - const name = `${me.displayName.split(' ')[0]}'s ${deviceName()}` + const name = speakerName(me.displayName) const start = () => { setStarting(true) - void speaker.start(room.id, name).finally(() => setStarting(false)) + const takingOver = !!other + void speaker + .start(room.id, name) + .then(async (np) => { + // "Play on this phone" means play: start a paused room too. Taking + // over from another speaker leaves it as it was. + if (!np || takingOver || np.state !== 'paused' || !can(room, me.id, 'playPause')) return + speaker.onState?.(await sendCommand(room.id, { action: 'play' })) + }) + .catch(() => { + // The speaker is set up either way; play is a tap away. + }) + .finally(() => setStarting(false)) } const listen = () => speaker.listen(room.id, name, playback.data, allowed) @@ -141,13 +153,13 @@ function speakerHint(status: string) { return 'Leave this open; it keeps playing locked' } -function listenerHint(status: string, speakerName?: string, roomState?: string) { +function listenerHint(status: string, speakerWho?: string, roomState?: string) { if (status === 'remote') return 'This song plays on its own service' if (status === 'blocked') return 'The browser needs a tap first' if (status === 'paused') return roomState === 'playing' ? 'Paused here; the room plays on' : 'The room is paused' - if (!speakerName) return 'Waiting for a speaker' - if (status === 'waiting') return `Waiting for ${speakerName}` - return `In time with ${speakerName}` + if (!speakerWho) return 'Waiting for a speaker' + if (status === 'waiting') return `Waiting for ${speakerWho}` + return `In time with ${speakerWho}` } /** What to call this device in "play on this …". */ diff --git a/web/src/components/room/up-next.tsx b/web/src/components/room/up-next.tsx index 0531cf5..ef3d8c2 100644 --- a/web/src/components/room/up-next.tsx +++ b/web/src/components/room/up-next.tsx @@ -1,6 +1,7 @@ import { GripVertical } from 'lucide-react' import { AnimatePresence, Reorder, useDragControls } from 'motion/react' import { useState } from 'react' +import { usePlayNow, type PlayNow } from '@/hooks/use-play-now' import { useQueueMove } from '@/hooks/use-queue-move' import { isMine } from '@/lib/autopilot' import { spring } from '@/lib/motion' @@ -42,6 +43,7 @@ export function UpNext({ const mineIds = new Set(items.filter((i) => isMine(i, me) && i.state === 'queued').map((i) => i.id)) const move = useQueueMove(roomId) + const playNow = usePlayNow(roomId) const dropped = (id: string) => { if (draft) { @@ -80,6 +82,7 @@ export function UpNext({ owner={owner} user={userById(item.addedBy)} byline={byline} + playNow={playNow} onDrop={mineIds.has(id) ? () => dropped(id) : undefined} /> ) @@ -99,6 +102,7 @@ function UpNextRow({ owner, user, byline, + playNow, onDrop, }: { roomId: string @@ -109,6 +113,7 @@ function UpNextRow({ owner?: boolean user?: User byline?: boolean + playNow?: PlayNow onDrop?: () => void }) { const controls = useDragControls() @@ -130,6 +135,8 @@ function UpNextRow({ user={user} mine={mine} byline={byline || !!item.autopilot} + onPlay={playNow && (() => playNow.play(item))} + playLabel={playNow?.mode === 'ask' ? 'Ask to play now' : 'Play now'} leading={{index}} trailing={ item.autopilot ? ( diff --git a/web/src/components/shell/app-shell.tsx b/web/src/components/shell/app-shell.tsx index 7005fbc..dcc8006 100644 --- a/web/src/components/shell/app-shell.tsx +++ b/web/src/components/shell/app-shell.tsx @@ -7,6 +7,7 @@ import { useBeatSync } from '@/hooks/use-beat-sync' import { easeOutExpo } from '@/lib/motion' import { usePlayer } from '@/lib/now-playing' import { useOnline } from '@/lib/pwa' +import { PlayNowRequest } from '@/components/room/play-now-request' import { Toaster } from '@/components/toaster' import { LightsMode } from '@/components/visualizer/lights-mode' import { VisualizerMode } from '@/components/visualizer/visualizer-mode' @@ -52,6 +53,7 @@ export function AppShell() {
+ setExpanded(true)} /> diff --git a/web/src/hooks/use-play-now.ts b/web/src/hooks/use-play-now.ts new file mode 100644 index 0000000..b27004e --- /dev/null +++ b/web/src/hooks/use-play-now.ts @@ -0,0 +1,52 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { errorMessage } from '@/api/errors' +import { useMe } from '@/lib/auth' +import { tap } from '@/lib/haptics' +import { playbackQuery, sendCommand, type PlaybackCommand, type QueueItem } from '@/lib/playback' +import { roomsQuery } from '@/lib/room' +import { toast } from '@/lib/toast' + +export type PlayNow = { + /** `now` plays it straight away; `ask` asks the room, which votes. */ + mode: 'now' | 'ask' + play: (item: QueueItem) => void +} + +/** + * Playing a queued song now, skipping the one on. It's the skip + * permission's call: in a room that votes on skips, you ask the room + * instead (the owner never has to). Guests can't. + */ +export function usePlayNow(roomId: string): PlayNow | undefined { + const me = useMe() + const rooms = useQuery(roomsQuery) + const command = usePlaybackCommand(roomId) + const room = rooms.data?.find((r) => r.id === roomId) + if (!room || me.guest) return undefined + const skip = room.permissions.skip + const mode = room.ownerId === me.id || skip === 'everyone' ? 'now' : skip === 'vote' ? 'ask' : undefined + if (!mode) return undefined + return { + mode, + play: (item) => { + tap() + void command({ action: 'play_now', itemId: item.id }).then((np) => { + if (np?.playNow?.itemId === item.id) toast({ message: `Asked the room to play “${item.track.title}”` }) + }) + }, + } +} + +/** Sends a playback command and keeps the cache in step, or says why it failed. */ +export function usePlaybackCommand(roomId: string) { + const queryClient = useQueryClient() + return async (body: PlaybackCommand) => { + try { + const np = await sendCommand(roomId, body) + queryClient.setQueryData(playbackQuery(roomId).queryKey, np) + return np + } catch (err) { + toast({ message: errorMessage(err), tone: 'error' }) + } + } +} diff --git a/web/src/hooks/use-room-controls.ts b/web/src/hooks/use-room-controls.ts index 49c3186..23ea8c6 100644 --- a/web/src/hooks/use-room-controls.ts +++ b/web/src/hooks/use-room-controls.ts @@ -6,6 +6,7 @@ import type { PlayerCommands } from '@/lib/now-playing' import { isMine } from '@/lib/autopilot' import { can, playbackQuery, sendCommand, skipMode, type Permission, type Playback, type PlaybackCommand } from '@/lib/playback' import type { Room } from '@/lib/room' +import { speaker, speakerName } from '@/lib/speaker' import { toast } from '@/lib/toast' /** @@ -14,13 +15,18 @@ import { toast } from '@/lib/toast' * room that votes on skips, everyone else gets a vote instead of `next`. * Guests only skip their own songs, and vote if the room lets them. */ -export function useRoomControls(room: Room | undefined, playback: Playback | undefined, userId: string, guest = false): PlayerCommands { +export function useRoomControls( + room: Room | undefined, + playback: Playback | undefined, + me: { id: string; displayName: string }, + guest = false, +): PlayerCommands { const queryClient = useQueryClient() + const userId = me.id return useMemo(() => { - if (!room || !playback?.item) return {} + if (!room || !playback) return {} const key = playbackQuery(room.id).queryKey - const item = playback.item const send = async (cmd: PlaybackCommand, optimistic?: (p: Playback) => Playback) => { tap() const before = queryClient.getQueryData(key) @@ -34,17 +40,30 @@ export function useRoomControls(room: Room | undefined, playback: Playback | und } const playing = playback.state === 'playing' || playback.state === 'loading' const allowed = (p: Permission) => !guest && can(room, userId, p) + // With no speaker, play makes this device it and starts the room, in + // the one tap (browsers only start audio from a tap). + const play = async () => { + if (!playback.player && allowed('speaker')) { + const np = await speaker.start(room.id, speakerName(me.displayName)) + if (!np || np.state === 'playing' || np.state === 'loading') return + } + await send({ action: 'play' }) + } + const toggle = allowed('playPause') + ? () => + playing + ? send({ action: 'pause' }, (p) => ({ ...p, state: 'paused', positionMs: currentPosition(p), at: new Date().toISOString() })) + : void play() + : undefined + const item = playback.item + // Nothing's on: play is all there is (it starts the next song). + if (!item) return { toggle } let skip = skipMode(room, userId, item) if (guest && !isMine(item, userId)) skip = skip === 'vote' && room.guests.canVote ? 'vote' : undefined const votes = playback.skipVotes const voted = !!votes?.voters.includes(userId) return { - toggle: allowed('playPause') - ? () => - playing - ? send({ action: 'pause' }, (p) => ({ ...p, state: 'paused', positionMs: currentPosition(p), at: new Date().toISOString() })) - : send({ action: 'play' }) - : undefined, + toggle, // There's no going back in a fair queue; "previous" restarts the song, // like most players do past the first few seconds. previous: allowed('seek') ? () => send({ action: 'seek', positionMs: 0 }, (p) => ({ ...p, positionMs: 0, at: new Date().toISOString() })) : undefined, @@ -69,7 +88,7 @@ export function useRoomControls(room: Room | undefined, playback: Playback | und ? (positionMs: number) => send({ action: 'seek', positionMs }, (p) => ({ ...p, positionMs, at: new Date().toISOString() })) : undefined, } - }, [room, playback, userId, guest, queryClient]) + }, [room, playback, userId, me.displayName, guest, queryClient]) } /** Where a playing song is now, from its position at the last report. */ diff --git a/web/src/lib/speaker.ts b/web/src/lib/speaker.ts index 8915df2..3febe2d 100644 --- a/web/src/lib/speaker.ts +++ b/web/src/lib/speaker.ts @@ -4,6 +4,7 @@ import { serverNow } from './clock' import { queueArtworkUrl, sendCommand, type Playback, type QueueItem } from './playback' import { createStore } from './store' import { toast } from './toast' +import { deviceName } from './webauthn' // Player mode: this device plays the room's audio (ADR 0003). The server // decides what plays; the speaker applies each state revision once and @@ -72,6 +73,11 @@ export function deviceId() { } let fallbackId: string | undefined +/** What to call this device as the speaker: "Collin's iPhone". */ +export function speakerName(displayName: string) { + return `${displayName.split(' ')[0]}'s ${deviceName()}` +} + /** Content types this browser can decode, for the stream's `accept`. */ export function acceptedTypes(probe: (type: string) => string = (t) => new Audio().canPlayType(t)) { return ['audio/mpeg', 'audio/aac', 'audio/mp4', 'audio/flac', 'audio/ogg', 'audio/wav', 'audio/webm'].filter( @@ -154,7 +160,7 @@ class Speaker { * before anything async happens. A paired TV passes its display ID as * the device, since that's who the server makes the speaker. */ - async start(roomId: string, name: string, device = deviceId()) { + async start(roomId: string, name: string, device = deviceId()): Promise { this.begin(roomId, 'speaker', name, device) try { const np = await unwrap( @@ -162,6 +168,7 @@ class Speaker { ) this.onState?.(np) this.apply(np) + return np } catch (err) { this.halt() toast({ message: errorMessage(err), tone: 'error' }) @@ -273,6 +280,9 @@ class Speaker { this.keepTime(np) } const listening = this.mode === 'listener' + // Tell the server we're on it: a song that never starts on a speaker + // that's said nothing gets paused, not skipped. + if (!listening && np.state === 'loading') this.report('progress') if (np.state === 'playing' || (!listening && np.state === 'loading')) { // Already playing it (we started the preloaded song when the last one // ended): no new 'playing' event will come, so tell the server now. diff --git a/web/src/routes/_app/_authed/room.tsx b/web/src/routes/_app/_authed/room.tsx index 3e6367e..e632ce3 100644 --- a/web/src/routes/_app/_authed/room.tsx +++ b/web/src/routes/_app/_authed/room.tsx @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query' import { createFileRoute, Link } from '@tanstack/react-router' -import { Check, ChevronDown, Crown, History, LogOut, MonitorPlay, Plus, QrCode, Settings2, Sparkles, Speaker, Users } from 'lucide-react' +import { Check, ChevronDown, Crown, History, LogOut, MonitorPlay, Play, Plus, QrCode, Settings2, Sparkles, Speaker, Users } from 'lucide-react' import { AnimatePresence, motion } from 'motion/react' import { DropdownMenu } from 'radix-ui' import { useEffect, useState, type ReactNode } from 'react' @@ -327,14 +327,22 @@ function NowPlayingCard({ room, waiting }: { room: RoomInfo; waiting: number })
{waiting > 0 ? : }
-

{waiting > 0 ? 'Ready when the speaker is' : 'The queue is quiet'}

+

+ {waiting > 0 ? (speaker ? 'Stopped' : 'Ready when the speaker is') : 'The queue is quiet'} +

{waiting > 0 ? speaker - ? `Waiting to start on ${speaker.name}.` + ? `${speaker.name} is the speaker. Press play to start the next song.` : 'Songs are waiting. Start playing on the device connected to the speaker.' : 'Be the first to put something on.'}

+ {waiting > 0 && speaker && commands.toggle && ( + + )}
0 && !speaker} />
From 25d4a6ef973471a285053db41b840e0ba0823a4e Mon Sep 17 00:00:00 2001 From: Collin Pendleton Date: Thu, 8 Oct 2026 09:29:57 -0500 Subject: [PATCH 13/15] Big screen keeps the music playing, and has play, pause and skip Opening the big screen on the device that was the speaker left the signed-in app, which released the speaker and paused the room, and the TV had no way to play again. Now leaving the app or the big screen hands the speaker over for a few seconds instead of letting go, and the next screen keeps it playing. The big screen gets play/pause and skip. Play with no speaker makes the screen the speaker and starts the room in one press, and "Play the audio here" starts a paused room. A paired screen with audio on may play, pause and skip as whoever paired it. The mouse pointer shows while it moves, so the buttons can be clicked on a desktop. Co-Authored-By: Claude Opus 5.5 --- api/openapi.yaml | 7 ++- server/internal/httpapi/displays.go | 4 ++ server/internal/httpapi/displays_test.go | 18 ++++++ server/internal/httpapi/playback.go | 16 ++++- server/internal/httpapi/server_test.go | 1 + web/src/api/schema.gen.ts | 4 +- web/src/components/room-live.tsx | 8 ++- web/src/components/tv/tv-audio.tsx | 75 +++++++++++++++++++++--- web/src/components/tv/tv-stage.tsx | 30 +++++++++- web/src/lib/speaker.ts | 32 ++++++++++ web/src/routes/tv.tsx | 8 ++- 11 files changed, 185 insertions(+), 18 deletions(-) diff --git a/api/openapi.yaml b/api/openapi.yaml index 0df5241..a9cb1b1 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2970,8 +2970,13 @@ paths: else sends `vote_skip` (and `unvote_skip` to take it back); the song is skipped once `skipVotes.needed` have voted. `play` needs a speaker (`no_player`); pausing, skipping, seeking or - voting with nothing playing is `nothing_playing`. + voting with nothing playing is `nothing_playing`. A display with + `audio` on may `play`, `pause` and `skip` in its own room, as + whoever paired it. tags: [playback] + security: + - session: [] + - display: [] requestBody: required: true content: diff --git a/server/internal/httpapi/displays.go b/server/internal/httpapi/displays.go index c322474..07d2d49 100644 --- a/server/internal/httpapi/displays.go +++ b/server/internal/httpapi/displays.go @@ -45,6 +45,7 @@ var displayOps = map[string]bool{ "ReleasePlayer": true, "ReportPlayback": true, "StreamQueueItem": true, + "ControlPlayback": true, } // audioOps are the displayOps that make a display the room's speaker. They @@ -54,6 +55,7 @@ var audioOps = map[string]bool{ "ReleasePlayer": true, "ReportPlayback": true, "StreamQueueItem": true, + "ControlPlayback": true, } // Display errors. @@ -72,6 +74,8 @@ func displayRoom(req any) (roomID string, ok bool) { return r.RoomId, true case GetPlaybackRequestObject: return r.RoomId, true + case ControlPlaybackRequestObject: + return r.RoomId, true case GetQueueItemArtworkRequestObject: return r.RoomId, true case GetQueueItemPaletteRequestObject: diff --git a/server/internal/httpapi/displays_test.go b/server/internal/httpapi/displays_test.go index d60cd2e..72856df 100644 --- a/server/internal/httpapi/displays_test.go +++ b/server/internal/httpapi/displays_test.go @@ -249,6 +249,21 @@ func TestDisplaySpeaker(t *testing.T) { if np.State != httpapi.PlaybackStatePlaying { t.Fatalf("after the display's report: %+v", np) } + // It has a speaker's buttons: play, pause and skip, as bob. Nothing else. + tv.want(http.StatusOK, "POST", base+"/playback", httpapi.PlaybackCommand{Action: httpapi.Pause}).decode(t, &np) + if np.State != httpapi.PlaybackStatePaused { + t.Fatalf("after the display paused: %+v", np) + } + tv.want(http.StatusOK, "POST", base+"/playback", httpapi.PlaybackCommand{Action: httpapi.Play}).decode(t, &np) + if np.State != httpapi.PlaybackStatePlaying { + t.Fatalf("after the display played: %+v", np) + } + if r := tv.do("POST", base+"/playback", httpapi.PlaybackCommand{Action: httpapi.Seek, PositionMs: ptr(int64(0))}); r.status != http.StatusForbidden { + t.Fatalf("display seeking: %d %s", r.status, r.body) + } + if r := tv.do("POST", "/rooms/"+other.Id+"/playback", httpapi.PlaybackCommand{Action: httpapi.Pause}); r.status != http.StatusForbidden { + t.Fatalf("display pausing another room: %d %s", r.status, r.body) + } // Bob's own phone isn't the TV. if r := bob.do("POST", base+"/player/report", httpapi.PlayerReport{DeviceId: "bobs-phone", ItemId: item, Event: "ended"}); r.status != http.StatusConflict { t.Fatalf("bob reporting as his phone: %d %s", r.status, r.body) @@ -267,6 +282,9 @@ func TestDisplaySpeaker(t *testing.T) { if r := tv.do("GET", base+"/stream/"+item, nil); r.status != http.StatusForbidden { t.Fatalf("streaming with audio off: %d", r.status) } + if r := tv.do("POST", base+"/playback", httpapi.PlaybackCommand{Action: httpapi.Play}); r.status != http.StatusForbidden { + t.Fatalf("playing with audio off: %d %s", r.status, r.body) + } // A display can let go itself; unpairing one that's the speaker stops it too. bob.want(http.StatusOK, "PATCH", base+"/displays/"+d.Id, httpapi.UpdateDisplayRequest{Audio: true}) diff --git a/server/internal/httpapi/playback.go b/server/internal/httpapi/playback.go index f14fc7f..447170f 100644 --- a/server/internal/httpapi/playback.go +++ b/server/internal/httpapi/playback.go @@ -35,7 +35,21 @@ func (s *Server) ControlPlayback(ctx context.Context, req ControlPlaybackRequest if req.Body.ItemId != nil { c.ItemID = *req.Body.ItemId } - np, err := s.Playback.Command(ctx, req.RoomId, sessionFrom(ctx).User.ID, c) + userID := "" + if d := displayFrom(ctx); d != nil { + // A screen playing the room has a speaker's buttons, as whoever paired it. + if c.Action != playback.ActionPlay && c.Action != playback.ActionPause && c.Action != playback.ActionSkip { + return nil, playback.ErrForbidden + } + id, _, err := speakerOf(ctx, "") + if err != nil { + return nil, err + } + userID = id + } else { + userID = sessionFrom(ctx).User.ID + } + np, err := s.Playback.Command(ctx, req.RoomId, userID, c) if err != nil { return nil, err } diff --git a/server/internal/httpapi/server_test.go b/server/internal/httpapi/server_test.go index c8fc043..a88ba4a 100644 --- a/server/internal/httpapi/server_test.go +++ b/server/internal/httpapi/server_test.go @@ -73,6 +73,7 @@ func TestDisplayOpsMatchSpec(t *testing.T) { var displayRequests = map[string]any{ "GetRoom": GetRoomRequestObject{}, "GetQueue": GetQueueRequestObject{}, + "ControlPlayback": ControlPlaybackRequestObject{}, "GetPlayback": GetPlaybackRequestObject{}, "GetQueueItemArtwork": GetQueueItemArtworkRequestObject{}, "GetQueueItemPalette": GetQueueItemPaletteRequestObject{}, diff --git a/web/src/api/schema.gen.ts b/web/src/api/schema.gen.ts index a6c6662..99a6ca7 100644 --- a/web/src/api/schema.gen.ts +++ b/web/src/api/schema.gen.ts @@ -2300,7 +2300,9 @@ export interface paths { * else sends `vote_skip` (and `unvote_skip` to take it back); the * song is skipped once `skipVotes.needed` have voted. * `play` needs a speaker (`no_player`); pausing, skipping, seeking or - * voting with nothing playing is `nothing_playing`. + * voting with nothing playing is `nothing_playing`. A display with + * `audio` on may `play`, `pause` and `skip` in its own room, as + * whoever paired it. */ post: operations["controlPlayback"]; delete?: never; diff --git a/web/src/components/room-live.tsx b/web/src/components/room-live.tsx index 6c51cd3..c7a66c6 100644 --- a/web/src/components/room-live.tsx +++ b/web/src/components/room-live.tsx @@ -32,8 +32,12 @@ export function RoomLive() { useEffect(() => { if (speaker.active && rooms.isSuccess && speakerRoom() !== room?.id) void speaker.stop() }, [room, rooms.isSuccess]) - // Signing out stops the speaker. - useEffect(() => () => void speaker.stop(), []) + // Signing out stops the speaker. Going to the big screen hands it over + // instead, and coming back keeps whatever it's playing. + useEffect(() => () => speaker.stopSoon(), []) + useEffect(() => { + if (room) speaker.keep(room.id) + }, [room]) // On the next frame, so a burst of updates (a phone waking with songs' // worth of events queued up) shows only the last, and none while hidden. diff --git a/web/src/components/tv/tv-audio.tsx b/web/src/components/tv/tv-audio.tsx index 660b558..6bb40a6 100644 --- a/web/src/components/tv/tv-audio.tsx +++ b/web/src/components/tv/tv-audio.tsx @@ -1,9 +1,10 @@ import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Speaker, Volume2 } from 'lucide-react' -import { useEffect, useRef } from 'react' +import { Pause, Play, SkipForward, Speaker, Volume2 } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' +import { errorMessage } from '@/api/errors' import { Equalizer } from '@/components/equalizer' import { Button } from '@/components/ui/button' -import { newer, playbackQuery } from '@/lib/playback' +import { newer, playbackQuery, sendCommand, type PlaybackCommand } from '@/lib/playback' import { speaker, speakerState } from '@/lib/speaker' import { useStore } from '@/lib/store' import { cn } from '@/lib/utils' @@ -15,15 +16,20 @@ const AUDIO_KEY = 'syncphony-tv-audio' * press, so it offers a button to press OK on with the remote. Once it's * playing, it starts again by itself after a reload, unless another device * has taken over in the meantime. If the browser still wants a press, - * the button comes back. + * the button comes back. Opening the big screen on a device that's + * already the speaker carries on playing. It has a speaker's buttons + * too: play, pause and skip. */ -export function TvAudio({ roomId, device, name, onStopped }: { +export function TvAudio({ roomId, device, name, onStopped, canPlayPause = true, canSkip = true }: { roomId: string /** Who the server knows this screen as: its display ID, or this browser's device ID. */ device: string name: string /** It stopped being the speaker without being asked to (its audio was turned off, say). */ onStopped?: () => void + /** Whether to offer play and pause, and skip. The server has the last word. */ + canPlayPause?: boolean + canSkip?: boolean }) { const queryClient = useQueryClient() const state = useStore(speakerState) @@ -31,14 +37,17 @@ export function TvAudio({ roomId, device, name, onStopped }: { const here = state.roomId === roomId && state.status !== 'off' const other = playback.data?.player && playback.data.player.deviceId !== device ? playback.data.player : undefined - // The server's state drives the speaker; its reports' replies flow back into the cache. + // The server's state drives the speaker; its reports' replies flow back + // into the cache. Already playing this room (we came from the app): keep + // going, and pick it up again after a reload. Leaving hands it back. useEffect(() => { speaker.onState = (np) => queryClient.setQueryData(playbackQuery(np.roomId).queryKey, (old) => newer(old, np)) + if (speaker.keep(roomId)) writeFlag(true) return () => { speaker.onState = undefined - void speaker.stop() + speaker.stopSoon() } - }, [queryClient]) + }, [queryClient, roomId]) useEffect(() => speaker.apply(playback.data), [playback.data]) // Pick up where it left off after a reload, unless someone else is playing the room now. @@ -46,6 +55,7 @@ export function TvAudio({ roomId, device, name, onStopped }: { useEffect(() => { if (resumed.current || !playback.data) return resumed.current = true + if (speaker.active) return const player = playback.data.player if (readFlag() && (!player || player.deviceId === device)) void speaker.start(roomId, name, device) }, [playback.data, roomId, name, device]) @@ -57,10 +67,52 @@ export function TvAudio({ roomId, device, name, onStopped }: { was.current = here }, [here, onStopped]) + const [error, setError] = useState() + useEffect(() => { + if (!error) return + const t = window.setTimeout(() => setError(undefined), 5000) + return () => window.clearTimeout(t) + }, [error]) + const command = async (body: PlaybackCommand) => { + try { + const np = await sendCommand(roomId, body) + queryClient.setQueryData(playbackQuery(roomId).queryKey, (old) => newer(old, np)) + } catch (err) { + setError(errorMessage(err)) + } + } + // Playing here means playing: a paused room starts too, unless we're + // taking over from another speaker, which carries on as it was. const start = () => { writeFlag(true) - void speaker.start(roomId, name, device) + const takingOver = !!other + void speaker.start(roomId, name, device).then((np) => { + if (np && !takingOver && np.state === 'paused' && canPlayPause) void command({ action: 'play' }) + }) } + const np = playback.data + const playing = np?.state === 'playing' || np?.state === 'loading' + // Play with no speaker makes this screen it, in the one press. + const toggle = () => (playing ? command({ action: 'pause' }) : np?.player ? command({ action: 'play' }) : start()) + const transport = canPlayPause && np && (np.item || np.next) && ( +
+ + {canSkip && np.item && ( + + )} +
+ ) + const problem = error && {error} const stop = () => { writeFlag(false) void speaker.stop() @@ -80,6 +132,8 @@ export function TvAudio({ roomId, device, name, onStopped }: { if (here) { return (
+ {problem} + {transport} {state.status === 'playing' ? : } {state.status === 'remote' @@ -99,6 +153,8 @@ export function TvAudio({ roomId, device, name, onStopped }: { return (
+ {problem} + {other && transport} {other && Playing on {other.name}}