Slow every animation down for a low-res screen

Every speed in config.js is in pixels per second, so the same numbers cross a
small screen in less time than a big one: on 1280x800 the frame covers the
width in two thirds of the time it takes on 1920x1080, and the player reads as
hurried.

One factor now sits behind every animation clock — tickSeconds() for the
frame loops, `speed` for the corners timer, and a --motion-stretch custom
property for the two CSS fades — so the Speed switch on the queue page slows
the drift, the glides, the pong rally, the drag beats, the wash lap, the
screenshot intervals and the fades together. It is read per frame rather than
baked into the constants, so flipping the switch lands on the next frame: no
animation restarts, and one part-way through a beat keeps its place.

The trail's stills now age on animation time rather than wall-clock time, so a
still fades at the rate the frame that dropped it is moving. The audio
crossfade is deliberately left out, being tied to the track that is ending.

The flag is saved with the queue, unlike `paused`: it describes the screen the
player is on, which a restart does not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
This commit is contained in:
Si
2026-08-14 12:53:33 +02:00
co-authored by Claude Opus 5
parent 3f00897f0e
commit 554b946235
10 changed files with 184 additions and 27 deletions
+25 -3
View File
@@ -9,8 +9,8 @@ A YouTube playlist player for parties. Two browser windows run side by side:
overlap in a 5 second audio/video crossfade, so there is never silence overlap in a 5 second audio/video crossfade, so there is never silence
between tracks. between tracks.
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to - **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
reorder, delete, see how much playtime is left, and pick the background and reorder, delete, see how much playtime is left, and pick the background,
animation. animation and animation speed.
## Run ## Run
@@ -36,7 +36,7 @@ autoplay, so the first click is what unlocks audio. One click per session.
- The queue is saved to `queue.json` on every change and restored at startup. - 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, corner - Settings live in `config.js`: overlap length, frame size, drift speed, corner
screenshot interval and margin, the drag animation's size range, the screenshot interval and margin, the drag animation's size range, the
background zoom range, port, and the animation list. Everything there except background zoom range, the low-res speed factor, port, and the animation list. Everything there except
`PORT` is mirrored to the browser by the generated `/config.js` route in `PORT` is mirrored to the browser by the generated `/config.js` route in
`server/index.js`, so a new constant needs no second list. `server/index.js`, so a new constant needs no second list.
@@ -93,6 +93,22 @@ today:
a half per lap, and the frame's glow travels with it. The one still animation, a half per lap, and the frame's glow travels with it. The one still animation,
which is what makes it worth having between the moving ones. which is what makes it worth having between the moving ones.
### Speed
Every speed in `config.js` is in pixels per second, so the same numbers cross a
small screen in less time than a big one — on 1280x800 the frame covers the
width in two thirds of the time it takes on 1920x1080, and the whole player
reads as hurried. The queue page's **Speed** switch fixes that: **Low-res
screen** multiplies every animation clock by `LOW_RES_SPEED` (0.65, the
1280/1920 ratio), which slows the drift, the glides, the pong rally, the drag
beats, the wash lap, the screenshot intervals and the fades together.
It is one factor read per frame rather than a set of slower constants, so
flipping the switch takes effect on the next frame — no animation restarts, and
one part-way through a beat keeps its place. The audio crossfade is deliberately
left out: it is tied to the track that is ending. The setting is saved with the
queue, since it describes the screen the player is on.
The queue page's **Animation** buttons pin the player to one of them, which is The queue page's **Animation** buttons pin the player to one of them, which is
how you look at a single animation without waiting for the cycle to reach it. how you look at a single animation without waiting for the cycle to reach it.
**Auto** (the default) hands selection back to the shuffle bag. Switching takes **Auto** (the default) hands selection back to the shuffle bag. Switching takes
@@ -161,6 +177,12 @@ the same element immediately. Register it under a new name in the `ANIMATIONS`
map in `motion.js` **and** in the one in `config.js` (which supplies the label map in `motion.js` **and** in the one in `config.js` (which supplies the label
for the queue page's button), and it joins the rotation. for the queue page's button), and it joins the rotation.
Anything time-driven should take its seconds from `tickSeconds(now, last)`
rather than from the raw timestamps, and any timer should divide its interval by
`speed`. That is what puts a new animation under the **Speed** switch — and the
clamp inside it is also what stops a hidden tab from teleporting the frame when
it comes back.
Selection uses a shuffle bag (`createPicker`): every animation comes up exactly 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 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. row — including across the seam between two passes.
+13
View File
@@ -92,6 +92,19 @@ export const PONG_MISS_CHANCE = 0.25;
// Seconds the ball waits out of play after it gets past the paddle. // Seconds the ball waits out of play after it gets past the paddle.
export const PONG_SERVE_SECONDS = 1.1; export const PONG_SERVE_SECONDS = 1.1;
// Everything that moves is measured in pixels per second, so the same speed
// crosses a small screen in less time than a big one: on 1280x800 the frame
// covers the width in two thirds of the time it takes on 1920x1080, and the
// whole player reads as hurried. The low-res switch on the queue page multiplies
// every animation clock — drift, glides, the pong rally, the drag beats, the
// wash lap, the screenshot intervals and the fades — by this one factor, which
// is the 1280/1920 ratio rounded off. Lower it to slow the party down further.
export const LOW_RES_SPEED = 0.65;
// Whether the player starts in that mode. Persisted with the queue, so a screen
// that needs it only has to be told once.
export const DEFAULT_LOW_RES = false;
// The frame animations, keyed by the id sent over the socket. The names must // The frame animations, keyed by the id sent over the socket. The names must
// match the registry in public/player/motion.js, which supplies the code. // match the registry in public/player/motion.js, which supplies the code.
export const ANIMATIONS = { export const ANIMATIONS = {
+66 -16
View File
@@ -36,6 +36,37 @@ import {
const ASPECT = 9 / 16; const ASPECT = 9 / 16;
// --- speed --------------------------------------------------------------
// One factor behind every animation clock, so the low-res switch slows the
// whole player rather than each animation carrying its own idea of "slower".
// 1 is the speed the constants in config.js are written for.
let speed = 1;
/**
* Set the speed every animation runs at. Takes effect on the next frame — no
* animation has to be restarted, and one part-way through a beat keeps its
* place rather than jumping.
*
* The CSS fades (the poster crossfade, a corner still appearing) are stretched
* with it through a custom property, since those durations live in the
* stylesheet: at half speed a fade takes twice as long, hence the reciprocal.
*/
export function setSpeed(value) {
speed = Number.isFinite(value) && value > 0 ? value : 1;
document.documentElement.style.setProperty('--motion-stretch', (1 / speed).toFixed(3));
return speed;
}
/**
* Seconds of animation time between two frame timestamps: real time, clamped so
* a stalled or hidden tab does not teleport anything on resume, then scaled by
* the current speed.
*/
function tickSeconds(now, last) {
return Math.min((now - last) / 1000, 0.05) * speed;
}
/** /**
* Frame size for a viewport: `scale` of the width, but never taller than * Frame size for a viewport: `scale` of the width, but never taller than
* `scale` of the height, so a wide screen does not get a letterbox strip. * `scale` of the height, so a wide screen does not get a letterbox strip.
@@ -179,8 +210,7 @@ export function startBounce({ element }) {
function frame(now) { function frame(now) {
if (stopped) return; if (stopped) return;
// Clamped so a stalled tab does not teleport the frame on resume. const dt = tickSeconds(now, last);
const dt = Math.min((now - last) / 1000, 0.05);
last = now; last = now;
bouncer.step(dt, bounds()); bouncer.step(dt, bounds());
@@ -307,10 +337,21 @@ export function startCorners(context) {
layout(); layout();
window.addEventListener('resize', layout); window.addEventListener('resize', layout);
const timer = setInterval(shoot, CORNER_INTERVAL_SECONDS * 1000);
// A chain of timeouts rather than an interval: the gap is read again after
// every shot, so a change of speed lands on the next one instead of waiting
// for the animation to be restarted.
let timer = 0;
function schedule() {
timer = setTimeout(() => {
shoot();
schedule();
}, (CORNER_INTERVAL_SECONDS * 1000) / speed);
}
schedule();
return function stop() { return function stop() {
clearInterval(timer); clearTimeout(timer);
window.removeEventListener('resize', layout); window.removeEventListener('resize', layout);
for (const cell of cells) cell.img.remove(); for (const cell of cells) cell.img.remove();
}; };
@@ -449,7 +490,7 @@ export function startTrail(context) {
} }
/** Photograph the frame where it stands. Reports whether a still landed. */ /** Photograph the frame where it stands. Reports whether a still landed. */
function drop(now) { function drop(clock) {
// Read late: the capture handle may still be waiting on the operator to // Read late: the capture handle may still be waiting on the operator to
// answer the screen-share prompt when this animation starts. // answer the screen-share prompt when this animation starts.
if (!context.capture) return false; if (!context.capture) return false;
@@ -470,7 +511,7 @@ export function startTrail(context) {
cell.img.style.width = `${rect.width}px`; cell.img.style.width = `${rect.width}px`;
cell.img.style.height = `${rect.height}px`; cell.img.style.height = `${rect.height}px`;
place(cell.img, rect.left, rect.top); place(cell.img, rect.left, rect.top);
cell.born = now; cell.born = clock;
cell.rect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }; cell.rect = { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom };
// Re-append so the freshest still paints over the rest of the tail. // Re-append so the freshest still paints over the rest of the tail.
layer.appendChild(cell.img); layer.appendChild(cell.img);
@@ -479,11 +520,11 @@ export function startTrail(context) {
return true; return true;
} }
function fade(now) { function fade(clock) {
for (const cell of cells) { for (const cell of cells) {
if (!cell.rect) continue; if (!cell.rect) continue;
const life = 1 - (now - cell.born) / 1000 / TRAIL_FADE_SECONDS; const life = 1 - (clock - cell.born) / TRAIL_FADE_SECONDS;
if (life <= 0) { if (life <= 0) {
cell.img.style.opacity = '0'; cell.img.style.opacity = '0';
// Gone, so it stops holding up drops that pass over its old spot. // Gone, so it stops holding up drops that pass over its old spot.
@@ -501,14 +542,18 @@ export function startTrail(context) {
window.addEventListener('resize', measure); window.addEventListener('resize', measure);
let last = performance.now(); let last = performance.now();
// Animation time rather than wall-clock time, so a still ages at the same
// rate the frame that dropped it is moving.
let clock = 0;
let handle = 0; let handle = 0;
let stopped = false; let stopped = false;
function frame(now) { function frame(now) {
if (stopped) return; if (stopped) return;
const dt = Math.min((now - last) / 1000, 0.05); const dt = tickSeconds(now, last);
last = now; last = now;
clock += dt;
const wasX = glider.x; const wasX = glider.x;
const wasY = glider.y; const wasY = glider.y;
@@ -518,9 +563,9 @@ export function startTrail(context) {
// Spacing by distance rather than by a timer keeps the stills evenly spread // Spacing by distance rather than by a timer keeps the stills evenly spread
// however the frame is turning. // however the frame is turning.
travelled += Math.hypot(glider.x - wasX, glider.y - wasY); travelled += Math.hypot(glider.x - wasX, glider.y - wasY);
if (travelled >= size.width * TRAIL_SPACING && drop(now)) travelled = 0; if (travelled >= size.width * TRAIL_SPACING && drop(clock)) travelled = 0;
fade(now); fade(clock);
handle = requestAnimationFrame(frame); handle = requestAnimationFrame(frame);
} }
@@ -884,9 +929,8 @@ export function startDragResize({ element }) {
function frame(now) { function frame(now) {
if (stopped) return; if (stopped) return;
const dt = Math.min((now - last) / 1000, 0.05); elapsed += tickSeconds(now, last);
last = now; last = now;
elapsed += dt;
const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1; const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1;
current.update(p); current.update(p);
@@ -959,14 +1003,20 @@ export function startWash({ element }) {
centre(); centre();
window.addEventListener('resize', centre); window.addEventListener('resize', centre);
const started = performance.now(); let last = performance.now();
// Counted up rather than measured from a start time, so the lap length
// follows the speed the way every other animation does.
let elapsed = 0;
let handle = 0; let handle = 0;
let stopped = false; let stopped = false;
function frame(now) { function frame(now) {
if (stopped) return; if (stopped) return;
const [from, to] = washHues((now - started) / 1000); elapsed += tickSeconds(now, last);
last = now;
const [from, to] = washHues(elapsed);
wash.style.background = `linear-gradient(135deg, ${washStop(from)}, ${washStop(to)})`; wash.style.background = `linear-gradient(135deg, ${washStop(from)}, ${washStop(to)})`;
// The frame's glow rides the leading hue, so it belongs to the wash instead // The frame's glow rides the leading hue, so it belongs to the wash instead
// of sitting on top of it in a fixed pink. // of sitting on top of it in a fixed pink.
@@ -1336,7 +1386,7 @@ export function startPong({ element }) {
function frame(now) { function frame(now) {
if (stopped) return; if (stopped) return;
const dt = Math.min((now - last) / 1000, 0.05); const dt = tickSeconds(now, last);
last = now; last = now;
// Out of play between the goal and the serve, so the score has a beat to be // Out of play between the goal and the serve, so the score has a beat to be
+6 -2
View File
@@ -1,6 +1,10 @@
:root { :root {
--accent: #ff4fc3; --accent: #ff4fc3;
--ink: #14060f; --ink: #14060f;
/* How much longer than written every animated duration below runs. Set by
motion.js from the speed the player is on: 1 normally, and above 1 on a
low-res screen, where everything is slowed down together. */
--motion-stretch: 1;
} }
* { * {
@@ -32,7 +36,7 @@ body {
width: 100%; width: 100%;
height: 100%; height: 100%;
opacity: 0; opacity: 0;
transition: opacity 900ms ease; transition: opacity calc(900ms * var(--motion-stretch)) ease;
/* Knock the poster back so the video frame stays the focal point — the /* Knock the poster back so the video frame stays the focal point — the
posters are pale and read far brighter on a projector than on a desk. */ posters are pale and read far brighter on a projector than on a desk. */
filter: brightness(0.45) saturate(1.15); filter: brightness(0.45) saturate(1.15);
@@ -97,7 +101,7 @@ body {
box-shadow: box-shadow:
0 0 0 4px rgba(255, 79, 195, 0.55), 0 0 0 4px rgba(255, 79, 195, 0.55),
0 24px 70px rgba(0, 0, 0, 0.7); 0 24px 70px rgba(0, 0, 0, 0.7);
transition: opacity 500ms ease; transition: opacity calc(500ms * var(--motion-stretch)) ease;
will-change: transform; will-change: transform;
} }
+29 -2
View File
@@ -1,7 +1,14 @@
import { connect } from '/shared/socket.js'; import { connect } from '/shared/socket.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, AUTO_BACKGROUND, AUTO_ANIMATION } from '/config.js'; import {
OVERLAP_SECONDS,
HARD_CUT_SECONDS,
AUTO_BACKGROUND,
AUTO_ANIMATION,
LOW_RES_SPEED,
DEFAULT_LOW_RES,
} from '/config.js';
import { createPlayer, loadApi, STATE } from './yt.js'; import { createPlayer, loadApi, STATE } from './yt.js';
import { startAnimationCycle } from './motion.js'; import { startAnimationCycle, setSpeed } from './motion.js';
import { startBackgroundCycle, showLogo } from './backdrop.js'; import { startBackgroundCycle, showLogo } from './backdrop.js';
import { requestCapture } from './capture.js'; import { requestCapture } from './capture.js';
@@ -12,6 +19,7 @@ let state = {
currentId: null, currentId: null,
background: AUTO_BACKGROUND, background: AUTO_BACKGROUND,
animation: AUTO_ANIMATION, animation: AUTO_ANIMATION,
lowRes: DEFAULT_LOW_RES,
paused: false, paused: false,
}; };
let decks = []; let decks = [];
@@ -22,6 +30,22 @@ let animations = null;
const socket = connect({ onMessage, onStatus }); const socket = connect({ onMessage, onStatus });
// --- speed --------------------------------------------------------------
/**
* Hand the low-res switch to the animations. Only the visuals slow down: the
* audio crossfade is tied to the track that is ending, and stretching that
* would leave two songs overlapping past the end of one of them.
*
* Applied from page load, before anything moves, so the poster's first
* crossfade is already at the right speed.
*/
function applySpeed(lowRes) {
setSpeed(lowRes ? LOW_RES_SPEED : 1);
}
applySpeed(state.lowRes);
// --- backgrounds -------------------------------------------------------- // --- backgrounds --------------------------------------------------------
// Runs from page load, not from the start gate: the poster is what is on // Runs from page load, not from the start gate: the poster is what is on
@@ -379,9 +403,12 @@ function onMessage(msg) {
const previousAnimation = state.animation; const previousAnimation = state.animation;
const previousBackground = state.background; const previousBackground = state.background;
const previousPaused = state.paused; const previousPaused = state.paused;
const previousLowRes = state.lowRes;
state = msg.state; state = msg.state;
if (state.background !== previousBackground) backgrounds.setMode(state.background); if (state.background !== previousBackground) backgrounds.setMode(state.background);
if (state.animation !== previousAnimation) animations?.setMode(state.animation); if (state.animation !== previousAnimation) animations?.setMode(state.animation);
// No restart: the running animation picks the new speed up on its next frame.
if (state.lowRes !== previousLowRes) applySpeed(state.lowRes);
if (state.paused !== previousPaused) applyPaused(state.paused); if (state.paused !== previousPaused) applyPaused(state.paused);
updateGate(); updateGate();
pumpProbe(); pumpProbe();
+4
View File
@@ -54,6 +54,10 @@
<span class="label">Animation</span> <span class="label">Animation</span>
<div id="animations"></div> <div id="animations"></div>
</div> </div>
<div class="switcher">
<span class="label">Speed</span>
<div id="speeds"></div>
</div>
</footer> </footer>
<script type="module" src="/queue/queue.js"></script> <script type="module" src="/queue/queue.js"></script>
+6 -3
View File
@@ -277,7 +277,8 @@ footer .switcher + .switcher {
} }
#backgrounds, #backgrounds,
#animations { #animations,
#speeds {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
@@ -285,7 +286,8 @@ footer .switcher + .switcher {
} }
#backgrounds button, #backgrounds button,
#animations button { #animations button,
#speeds button {
font: inherit; font: inherit;
padding: 8px 18px; padding: 8px 18px;
border-radius: 999px; border-radius: 999px;
@@ -296,7 +298,8 @@ footer .switcher + .switcher {
} }
#backgrounds button.active, #backgrounds button.active,
#animations button.active { #animations button.active,
#speeds button.active {
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
color: var(--ink); color: var(--ink);
+22 -1
View File
@@ -1,11 +1,18 @@
import { connect, formatDuration } from '/shared/socket.js'; import { connect, formatDuration } from '/shared/socket.js';
import { BACKGROUNDS, AUTO_BACKGROUND, ANIMATIONS, AUTO_ANIMATION } from '/config.js'; import {
BACKGROUNDS,
AUTO_BACKGROUND,
ANIMATIONS,
AUTO_ANIMATION,
DEFAULT_LOW_RES,
} from '/config.js';
let state = { let state = {
items: [], items: [],
currentId: null, currentId: null,
background: AUTO_BACKGROUND, background: AUTO_BACKGROUND,
animation: AUTO_ANIMATION, animation: AUTO_ANIMATION,
lowRes: DEFAULT_LOW_RES,
paused: false, paused: false,
}; };
// Last playhead report, kept with its arrival time so the countdown can be // Last playhead report, kept with its arrival time so the countdown can be
@@ -18,6 +25,7 @@ const errorBox = document.getElementById('error');
const statusBox = document.getElementById('status'); const statusBox = document.getElementById('status');
const backgroundBox = document.getElementById('backgrounds'); const backgroundBox = document.getElementById('backgrounds');
const animationBox = document.getElementById('animations'); const animationBox = document.getElementById('animations');
const speedBox = document.getElementById('speeds');
const playPause = document.getElementById('playpause'); const playPause = document.getElementById('playpause');
const socket = connect({ onMessage, onStatus }); const socket = connect({ onMessage, onStatus });
@@ -257,6 +265,18 @@ const renderAnimation = buildSwitcher(
() => state.animation, () => state.animation,
); );
// Not a row of names but a two-way switch, built the same way so it lights up
// and travels like the others. The speeds themselves live in config.js.
const renderSpeed = buildSwitcher(
speedBox,
[
[false, 'Normal'],
[true, 'Low-res screen'],
],
'setLowRes',
() => Boolean(state.lowRes),
);
// --- socket ------------------------------------------------------------- // --- socket -------------------------------------------------------------
function onMessage(msg) { function onMessage(msg) {
@@ -265,6 +285,7 @@ function onMessage(msg) {
render(); render();
renderBackground(); renderBackground();
renderAnimation(); renderAnimation();
renderSpeed();
} else if (msg.type === 'progress') { } else if (msg.type === 'progress') {
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() }; progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
renderTotals(); renderTotals();
+7
View File
@@ -166,6 +166,13 @@ const handlers = {
commit(); commit();
}, },
setLowRes(_ws, msg) {
if (typeof msg.key !== 'boolean') return;
if (store.getState().lowRes === msg.key) return;
store.getState().lowRes = msg.key;
commit();
},
// Relayed straight through: this arrives once a second and must never // Relayed straight through: this arrives once a second and must never
// touch the disk. // touch the disk.
progress(_ws, msg) { progress(_ws, msg) {
+6
View File
@@ -13,6 +13,7 @@ import {
DEFAULT_ANIMATION, DEFAULT_ANIMATION,
ANIMATIONS, ANIMATIONS,
AUTO_ANIMATION, AUTO_ANIMATION,
DEFAULT_LOW_RES,
} from '../config.js'; } from '../config.js';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -23,6 +24,9 @@ const state = {
currentId: null, currentId: null,
background: DEFAULT_BACKGROUND, background: DEFAULT_BACKGROUND,
animation: DEFAULT_ANIMATION, animation: DEFAULT_ANIMATION,
// Slow every animation down for a small screen. Saved, unlike `paused`: it
// describes the screen the player is on, which a restart does not change.
lowRes: DEFAULT_LOW_RES,
// Deliberately absent from save(): a restart should bring the music back, // Deliberately absent from save(): a restart should bring the music back,
// not leave the party silently paused because of a click hours ago. // not leave the party silently paused because of a click hours ago.
paused: false, paused: false,
@@ -57,6 +61,7 @@ export function load() {
} }
if (isBackground(saved.background)) state.background = saved.background; if (isBackground(saved.background)) state.background = saved.background;
if (isAnimation(saved.animation)) state.animation = saved.animation; if (isAnimation(saved.animation)) state.animation = saved.animation;
if (typeof saved.lowRes === 'boolean') state.lowRes = saved.lowRes;
// Resume at the item that was playing, otherwise at the top of the list. // Resume at the item that was playing, otherwise at the top of the list.
state.currentId = state.items.some((i) => i.id === saved.currentId) state.currentId = state.items.some((i) => i.id === saved.currentId)
? saved.currentId ? saved.currentId
@@ -79,6 +84,7 @@ export function save() {
currentId: state.currentId, currentId: state.currentId,
background: state.background, background: state.background,
animation: state.animation, animation: state.animation,
lowRes: state.lowRes,
}, },
null, null,
2, 2,