Cycle the frame through animations, add a corners effect

The frame had exactly one behaviour, started once and run forever. Make
animations a start(context) => stop() lifecycle held in a registry, and
draw a new one on every completed crossfade.

Selection uses a shuffle bag: each animation comes up exactly once per
pass, so they stay evenly used, and a swap at the pass boundary keeps the
same one from running twice in a row. Written for a registry larger than
today's two entries.

The new corners animation parks the frame in a random corner and drops a
screenshot of it into the next of the other three corners every 5s, going
clockwise. The frame's own corner is never a slot, so a still cannot
cover it by construction rather than by clamping. This holds while
FRAME_SCALE stays under 0.5, now noted where that knob lives.

Screenshots need getDisplayMedia: the decks are cross-origin YouTube
iframes, so canvas taints and there is no other way to read the playing
pixels. The prompt has to be requested before start() yields, or the
click's transient activation is gone. Declining, or serving over plain
HTTP where mediaDevices is undefined, leaves the frame parked without
stills instead of failing.

The capture sink is a 1x1 element in the document, like #probe: detached
video elements are never composited and get throttled hard enough to make
every screenshot seconds stale.

startBounce also gains the stop() it always needed — it was leaking its
rAF loop and resize listener, and a queued frame would fight whichever
animation came next.

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 22:04:33 +02:00
co-authored by Claude Opus 5
parent ad999e1c3e
commit ce1a6d6a47
8 changed files with 424 additions and 21 deletions
+35 -5
View File
@@ -3,9 +3,9 @@
A YouTube playlist player for parties. Two browser windows run side by side:
- **Player** (`http://localhost:3000/`) — fullscreen poster background with a
small video frame drifting diagonally and bouncing off the edges. Consecutive
videos overlap in a 5 second audio/video crossfade, so there is never silence
between tracks.
small video frame that moves around, switching animation on every track (see
[Animations](#animations)). Consecutive videos overlap in a 5 second
audio/video crossfade, so there is never silence between tracks.
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
reorder, delete, and see how much playtime is left.
@@ -31,8 +31,27 @@ autoplay, so the first click is what unlocks audio. One click per session.
showing it while the player page is closed). Totals are prefixed with `≥`
whenever a length is still unknown.
- The queue is saved to `queue.json` on every change and restored at startup.
- Settings live in `config.js`: overlap length, frame size, drift speed, port,
and the selectable backgrounds.
- Settings live in `config.js`: overlap length, frame size, drift speed, corner
screenshot interval and margin, port, and the selectable backgrounds. Anything
the browser needs must also be mirrored in the `/config.js` route in
`server/index.js`.
## Animations
The video frame's movement changes with every track. Two animations ship today:
- **bounce** — diagonal drift, reflecting off the screen edges.
- **corners** — the frame parks in a random corner and every 5 seconds a
screenshot of it appears in the next of the three other corners, going
clockwise. The frame never moves, so a screenshot can never cover it.
**The corners animation asks to share your screen.** The decks are cross-origin
YouTube iframes, so nothing on the page can read their pixels — capturing the
tab and cropping out the frame is the only way to photograph what is playing.
The prompt appears once, on the first click of **Start**; choose *this tab*.
Declining is fine: the frame still parks in its corner, just without the
screenshots. The prompt is skipped entirely on a plain-HTTP origin (i.e. when
you open the player from another machine over the network), for the same reason.
## Adding animations
@@ -40,3 +59,14 @@ autoplay, so the first click is what unlocks audio. One click per session.
`#frameSkin` inside it is the cosmetic layer, and the two video decks sit inside
that. New effects should nest a fresh wrapper rather than sharing a transform
with the bounce loop.
An animation is `start(context) => stop()`, where `context` is
`{ element, capture }` — `capture` may be null, and is read late, so an
animation must cope with it being absent. `stop()` has to undo everything:
timers, listeners, and any DOM it added, because the next animation takes over
the same element immediately. Add it to the `ANIMATIONS` array and it joins the
rotation.
Selection uses a shuffle bag (`createPicker`): every animation comes up exactly
once per pass, so they stay evenly used, and the same one never runs twice in a
row — including across the seam between two passes.
+9 -1
View File
@@ -9,12 +9,20 @@ export const OVERLAP_SECONDS = 5;
// How fast the player switches when the user jumps around manually.
export const HARD_CUT_SECONDS = 0.4;
// The video frame takes up this fraction of the screen.
// The video frame takes up this fraction of the screen. Keep this below 0.5:
// the corners animation puts a frame-sized image in all four corners, which
// only stays overlap-free while two frames fit side by side.
export const FRAME_SCALE = 0.3;
// Pixels per second the frame drifts across the screen.
export const BOUNCE_SPEED = 90;
// The corners animation: seconds between screenshots.
export const CORNER_INTERVAL_SECONDS = 5;
// Gap between the frame and the screen edge, as a fraction of the short side.
export const CORNER_MARGIN = 0.025;
// Selectable fullscreen backgrounds, keyed by the id sent over the socket.
export const BACKGROUNDS = {
pink: { label: 'Pink', src: '/assets/PLAKAT_A2_pink_426x600mm.png' },
+94
View File
@@ -0,0 +1,94 @@
// Screen capture, used to photograph the video frame.
//
// The decks are cross-origin YouTube iframes, so nothing on the page can read
// their pixels — canvas taints, html2canvas sees an empty box. Capturing the
// tab through getDisplayMedia and cropping out the frame's rect is the only way
// to get the actually-playing image.
/**
* Ask for the screen-share stream. Resolves to a `{ grab, stop }` handle, or to
* null if the operator declines or the API is missing — callers degrade rather
* than fail.
*
* Must be called synchronously from a user gesture (getDisplayMedia needs
* transient activation), so kick it off before any await and keep the promise.
*/
export function requestCapture() {
// Undefined on a plain-HTTP origin, which is exactly how the README tells
// people to reach this page from other machines on the network.
if (!navigator.mediaDevices?.getDisplayMedia) return Promise.resolve(null);
try {
return navigator.mediaDevices
.getDisplayMedia({
// preferCurrentTab keeps the stream to this tab's viewport, so stream
// pixels map to CSS pixels by one scale factor. Picking a whole screen
// instead would put browser chrome in the frame and skew every crop.
video: { frameRate: 8 },
audio: false,
preferCurrentTab: true,
})
.then(attach)
.catch(() => null);
} catch {
// Some rejections arrive synchronously. Never let them escape into
// start(), which would abort playback before the gate even lifts.
return Promise.resolve(null);
}
}
async function attach(stream) {
const [track] = stream.getVideoTracks();
if (!track) return null;
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
video.playsInline = true;
// Must be in the document, like #probe: a detached video is never composited
// and the browser throttles its frames hard, which would leave screenshots
// showing a moment several seconds stale. 1x1 and transparent, so it costs
// nothing and cannot show up in its own crop.
video.id = 'captureSink';
document.body.appendChild(video);
await video.play().catch(() => {});
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
return {
/**
* Crop a viewport rect out of the live stream. `rect` is in CSS pixels.
* Returns a data URL, or null while the stream has yet to deliver a frame.
*/
grab({ x, y, width, height }) {
if (video.readyState < 2 || !video.videoWidth) return null;
if (width <= 0 || height <= 0) return null;
const scaleX = video.videoWidth / window.innerWidth;
const scaleY = video.videoHeight / window.innerHeight;
canvas.width = Math.round(width * scaleX);
canvas.height = Math.round(height * scaleY);
context.drawImage(
video,
x * scaleX,
y * scaleY,
width * scaleX,
height * scaleY,
0,
0,
canvas.width,
canvas.height,
);
return canvas.toDataURL('image/jpeg', 0.85);
},
stop() {
for (const t of stream.getTracks()) t.stop();
video.srcObject = null;
video.remove();
},
};
}
+4
View File
@@ -9,6 +9,10 @@
<body>
<div id="backgrounds"></div>
<!-- Frozen screenshots of the frame. A sibling, not a child, of #frameMover:
these are positioned against the screen, not the moving frame. -->
<div id="stills"></div>
<!-- #frameMover owns the motion transform and nothing else, so future
animations can nest their own transform layers without conflict. -->
<div id="frameMover">
+194 -11
View File
@@ -1,7 +1,17 @@
// Frame motion. Everything here writes to #frameMover's transform and nothing
// else, so additional animation layers can be nested inside without conflict.
//
// An animation is `start(context) => stop()`. `context` carries the element to
// move and a capture handle (possibly null). `stop()` must undo everything the
// animation set up — timers, listeners, DOM — because the next animation takes
// over the same element immediately.
import { FRAME_SCALE, BOUNCE_SPEED } from '/config.js';
import {
FRAME_SCALE,
BOUNCE_SPEED,
CORNER_INTERVAL_SECONDS,
CORNER_MARGIN,
} from '/config.js';
const ASPECT = 9 / 16;
@@ -22,6 +32,58 @@ export function frameSize(viewportWidth, viewportHeight) {
return { width, height };
}
/** Size the frame to the current viewport and report the size back. */
function applyFrameSize(element) {
const size = frameSize(window.innerWidth, window.innerHeight);
element.style.width = `${size.width}px`;
element.style.height = `${size.height}px`;
return size;
}
function place(element, x, y) {
element.style.transform = `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0)`;
}
// --- picker -------------------------------------------------------------
/**
* Shuffle bag over 0..count-1: every animation comes up exactly once per pass,
* so selection stays even, and a swap at the bag boundary keeps the same one
* from landing twice in a row. Pure apart from the injected RNG.
*/
export function createPicker(count, random = Math.random) {
let bag = [];
let last = -1;
function refill() {
bag = Array.from({ length: count }, (_, i) => i);
// Fisher–Yates.
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
// next() pops off the end, so bag[length-1] is served first. A repeat can
// only happen right here, at the seam between two bags.
if (bag.length > 1 && bag[bag.length - 1] === last) {
const j = Math.floor(random() * (bag.length - 1));
[bag[bag.length - 1], bag[j]] = [bag[j], bag[bag.length - 1]];
}
}
return {
next() {
if (count <= 1) return 0;
if (bag.length === 0) refill();
last = bag.pop();
return last;
},
};
}
// --- bounce -------------------------------------------------------------
/**
* 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.
@@ -65,8 +127,8 @@ export function createBouncer({ x = 0, y = 0 } = {}) {
return bouncer;
}
export function startBounce(element) {
let size = frameSize(window.innerWidth, window.innerHeight);
export function startBounce({ element }) {
let size = applyFrameSize(element);
const bouncer = createBouncer({
x: (window.innerWidth - size.width) / 2,
y: (window.innerHeight - size.height) / 2,
@@ -80,31 +142,152 @@ export function startBounce(element) {
}
function measure() {
size = frameSize(window.innerWidth, window.innerHeight);
element.style.width = `${size.width}px`;
element.style.height = `${size.height}px`;
size = applyFrameSize(element);
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();
let handle = 0;
let stopped = false;
function frame(now) {
if (stopped) return;
// 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);
place(element, bouncer.x, bouncer.y);
handle = requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
handle = requestAnimationFrame(frame);
return bouncer;
return function stop() {
// The flag matters as much as the cancel: a frame already queued would
// otherwise fire once more and fight whatever animation comes next.
stopped = true;
cancelAnimationFrame(handle);
window.removeEventListener('resize', measure);
};
}
// --- corners ------------------------------------------------------------
// Clockwise from the top left.
const CORNERS = 4;
/** Top-left offset for a corner, inset by `margin` from the viewport edge. */
export function cornerPosition(index, size, viewport, margin) {
const right = index === 1 || index === 2;
const bottom = index === 2 || index === 3;
return {
x: right ? Math.max(margin, viewport.width - size.width - margin) : margin,
y: bottom ? Math.max(margin, viewport.height - size.height - margin) : margin,
};
}
/**
* The video parks in one random corner and stays there; every few seconds a
* screenshot of it lands in the next of the three *other* corners, cycling
* clockwise. Because the video's own corner is never a slot, a still can never
* cover the video — and since the crop only ever covers the video's rect, a
* still never contains another still either.
*/
export function startCorners(context) {
const { element } = context;
const layer = document.getElementById('stills');
const home = Math.floor(Math.random() * CORNERS);
// The three non-home corners, in clockwise order starting after the video.
const slots = [1, 2, 3].map((step) => (home + step) % CORNERS);
const images = slots.map(() => {
const img = document.createElement('img');
layer.appendChild(img);
return img;
});
let slot = 0;
let size;
function layout() {
size = applyFrameSize(element);
const viewport = { width: window.innerWidth, height: window.innerHeight };
const margin = Math.min(viewport.width, viewport.height) * CORNER_MARGIN;
const spot = cornerPosition(home, size, viewport, margin);
place(element, spot.x, spot.y);
images.forEach((img, i) => {
const at = cornerPosition(slots[i], size, viewport, margin);
img.style.width = `${size.width}px`;
img.style.height = `${size.height}px`;
place(img, at.x, at.y);
});
}
function shoot() {
// Read late: the capture handle may still be waiting on the operator to
// answer the screen-share prompt when this animation starts.
if (!context.capture) return;
const rect = element.getBoundingClientRect();
const shot = context.capture.grab(rect);
if (!shot) return;
const img = images[slot];
img.src = shot;
img.classList.add('visible');
slot = (slot + 1) % images.length;
}
layout();
window.addEventListener('resize', layout);
const timer = setInterval(shoot, CORNER_INTERVAL_SECONDS * 1000);
return function stop() {
clearInterval(timer);
window.removeEventListener('resize', layout);
for (const img of images) img.remove();
};
}
// --- registry -----------------------------------------------------------
export const ANIMATIONS = [
{ name: 'bounce', start: startBounce },
{ name: 'corners', start: startCorners },
];
/**
* Run one animation at a time, swapping to a freshly picked one on next().
* Returns the handle the player drives from its track changes.
*/
export function startAnimationCycle(context) {
const picker = createPicker(ANIMATIONS.length);
let stop = null;
let current = null;
function next() {
stop?.();
current = ANIMATIONS[picker.next()];
stop = current.start(context);
return current.name;
}
next();
return {
next,
get name() {
return current?.name ?? null;
},
};
}
+44
View File
@@ -41,12 +41,43 @@ body {
opacity: 1;
}
/* --- frozen screenshots ------------------------------------------------ */
#stills {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 1;
}
#stills img {
position: absolute;
top: 0;
left: 0;
object-fit: cover;
opacity: 0;
/* Matches #frameSkin, so a still reads as a frozen copy of the frame. */
border-radius: 10px;
border: 2px solid rgba(255, 255, 255, 0.85);
box-shadow:
0 0 0 4px rgba(255, 79, 195, 0.55),
0 24px 70px rgba(0, 0, 0, 0.7);
transition: opacity 500ms ease;
will-change: transform;
}
#stills img.visible {
opacity: 1;
}
/* --- the moving frame -------------------------------------------------- */
#frameMover {
position: fixed;
top: 0;
left: 0;
/* Above the stills, so the live video always wins if anything ever overlaps. */
z-index: 2;
will-change: transform;
}
@@ -80,6 +111,19 @@ body {
pointer-events: none;
}
/* The screen-capture stream's sink. Has to be in the document to keep getting
frames (see capture.js), but must not be visible or catch clicks. */
#captureSink {
position: fixed;
left: 0;
top: 0;
width: 1px;
height: 1px;
opacity: 0.01;
pointer-events: none;
z-index: -1;
}
#probe {
position: fixed;
left: -400px;
+32 -3
View File
@@ -1,7 +1,8 @@
import { connect } from '/shared/socket.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.js';
import { createPlayer, loadApi, STATE } from './yt.js';
import { startBounce } from './motion.js';
import { startAnimationCycle } from './motion.js';
import { requestCapture } from './capture.js';
const TICK_MS = 250;
@@ -10,6 +11,7 @@ let decks = [];
let active = 0;
let transition = null; // { until, duration, from, to } while crossfading
let started = false;
let animations = null;
const socket = connect({ onMessage, onStatus });
@@ -55,9 +57,24 @@ function updateGate() {
async function start() {
if (started) return;
started = true;
// Before any await: getDisplayMedia needs the click's transient activation,
// which is gone the moment this function yields.
const capturing = requestCapture();
gate.classList.add('hidden');
startBounce(document.getElementById('frameMover'));
// Mutable context: the animations start now rather than stalling behind the
// permission prompt, and read `capture` when they actually take a screenshot.
const animationContext = {
element: document.getElementById('frameMover'),
capture: null,
};
capturing.then((capture) => {
animationContext.capture = capture;
});
animations = startAnimationCycle(animationContext);
decks = await Promise.all([
makeDeck('deck-a', 0),
@@ -230,6 +247,9 @@ function finishTransition() {
decks[to].setOpacity(1);
active = to;
// New track, new animation.
animations?.next();
if (advance) socket.send({ type: 'trackEnded', id: outgoingId });
}
@@ -324,7 +344,16 @@ window.__player = {
const duration = deck?.player.getDuration?.() ?? 0;
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
},
nextAnimation() {
return animations?.next() ?? null;
},
get state() {
return { state, active, transition, decks: decks.map((d) => d.itemId) };
return {
state,
active,
transition,
decks: decks.map((d) => d.itemId),
animation: animations?.name ?? null,
};
},
};
+12 -1
View File
@@ -7,7 +7,16 @@ import { fileURLToPath } from 'node:url';
import express from 'express';
import { WebSocketServer } from 'ws';
import { PORT, OVERLAP_SECONDS, HARD_CUT_SECONDS, FRAME_SCALE, BOUNCE_SPEED, BACKGROUNDS } from '../config.js';
import {
PORT,
OVERLAP_SECONDS,
HARD_CUT_SECONDS,
FRAME_SCALE,
BOUNCE_SPEED,
CORNER_INTERVAL_SECONDS,
CORNER_MARGIN,
BACKGROUNDS,
} from '../config.js';
import { parseVideoId, fetchTitle } from './youtube.js';
import * as store from './state.js';
@@ -32,6 +41,8 @@ app.get('/config.js', (_req, res) => {
`export const HARD_CUT_SECONDS = ${HARD_CUT_SECONDS};\n` +
`export const FRAME_SCALE = ${FRAME_SCALE};\n` +
`export const BOUNCE_SPEED = ${BOUNCE_SPEED};\n` +
`export const CORNER_INTERVAL_SECONDS = ${CORNER_INTERVAL_SECONDS};\n` +
`export const CORNER_MARGIN = ${CORNER_MARGIN};\n` +
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n`,
);
});