Add party player: YouTube queue with crossfading playback

Two frontends sharing one WebSocket-backed state on the server:

- Player: fullscreen poster background with a small frame (30% of the
  screen) that drifts diagonally and reflects off the edges. Two stacked
  YouTube players hand over with a 5s equal-power crossfade so there is
  never silence between tracks.
- Queue: add by URL, drag to reorder, delete, click to jump. Shows title
  and length per item, highlights what is playing, and tracks remaining
  vs. total playtime.

No API key is required — titles come from YouTube's oEmbed endpoint and
durations from a hidden muted probe player that cues each new video just
to read its length.

The fade is driven by both requestAnimationFrame and the interval tick:
a hidden or throttled tab stops serving frames entirely, which would
otherwise strand a handover half-faded with two tracks audible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
simon
2026-08-09 21:22:13 +02:00
co-authored by Claude Opus 5
commit ad999e1c3e
17 changed files with 2540 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
// Frame motion. Everything here writes to #frameMover's transform and nothing
// else, so additional animation layers can be nested inside without conflict.
import { FRAME_SCALE, BOUNCE_SPEED } from '/config.js';
const ASPECT = 9 / 16;
/**
* Frame size for a viewport: FRAME_SCALE of the width, but never taller than
* FRAME_SCALE of the height, so a wide screen does not get a letterbox strip.
*/
export function frameSize(viewportWidth, viewportHeight) {
let width = viewportWidth * FRAME_SCALE;
let height = width * ASPECT;
const maxHeight = viewportHeight * FRAME_SCALE;
if (height > maxHeight) {
height = maxHeight;
width = height / ASPECT;
}
return { width, height };
}
/**
* Diagonal drift with reflection off the viewport edges. Pure state + step, so
* the physics can be exercised without a browser and without waiting on rAF.
*/
export function createBouncer({ x = 0, y = 0 } = {}) {
const bouncer = {
x,
y,
// A shallow diagonal reads better than 45°, and never retraces its path.
vx: BOUNCE_SPEED,
vy: BOUNCE_SPEED * 0.62,
step(dt, bounds) {
const maxX = Math.max(0, bounds.width);
const maxY = Math.max(0, bounds.height);
bouncer.x += bouncer.vx * dt;
bouncer.y += bouncer.vy * dt;
// Reflect, mirroring the overshoot back inside the viewport.
if (bouncer.x <= 0) {
bouncer.x = Math.min(-bouncer.x, maxX);
bouncer.vx = Math.abs(bouncer.vx);
} else if (bouncer.x >= maxX) {
bouncer.x = Math.max(maxX - (bouncer.x - maxX), 0);
bouncer.vx = -Math.abs(bouncer.vx);
}
if (bouncer.y <= 0) {
bouncer.y = Math.min(-bouncer.y, maxY);
bouncer.vy = Math.abs(bouncer.vy);
} else if (bouncer.y >= maxY) {
bouncer.y = Math.max(maxY - (bouncer.y - maxY), 0);
bouncer.vy = -Math.abs(bouncer.vy);
}
return bouncer;
},
};
return bouncer;
}
export function startBounce(element) {
let size = frameSize(window.innerWidth, window.innerHeight);
const bouncer = createBouncer({
x: (window.innerWidth - size.width) / 2,
y: (window.innerHeight - size.height) / 2,
});
function bounds() {
return {
width: window.innerWidth - size.width,
height: window.innerHeight - size.height,
};
}
function measure() {
size = frameSize(window.innerWidth, window.innerHeight);
element.style.width = `${size.width}px`;
element.style.height = `${size.height}px`;
const limit = bounds();
bouncer.x = Math.min(bouncer.x, Math.max(0, limit.width));
bouncer.y = Math.min(bouncer.y, Math.max(0, limit.height));
}
measure();
window.addEventListener('resize', measure);
let last = performance.now();
function frame(now) {
// Clamped so a stalled tab does not teleport the frame on resume.
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
bouncer.step(dt, bounds());
element.style.transform = `translate3d(${bouncer.x.toFixed(1)}px, ${bouncer.y.toFixed(1)}px, 0)`;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
return bouncer;
}