diff --git a/README.md b/README.md index bec27c0..a4bf7f9 100644 --- a/README.md +++ b/README.md @@ -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 between tracks. - **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to - reorder, delete, see how much playtime is left, and pick the background and - animation. + reorder, delete, see how much playtime is left, and pick the background, + animation and animation speed. ## 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. - Settings live in `config.js`: overlap length, frame size, drift speed, corner 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 `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, 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 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 @@ -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 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 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. diff --git a/config.js b/config.js index 4053f3a..bd7c0e6 100644 --- a/config.js +++ b/config.js @@ -92,6 +92,19 @@ export const PONG_MISS_CHANCE = 0.25; // Seconds the ball waits out of play after it gets past the paddle. 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 // match the registry in public/player/motion.js, which supplies the code. export const ANIMATIONS = { diff --git a/public/player/motion.js b/public/player/motion.js index 5cd54b3..11c6600 100644 --- a/public/player/motion.js +++ b/public/player/motion.js @@ -36,6 +36,37 @@ import { 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 * `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) { if (stopped) return; - // Clamped so a stalled tab does not teleport the frame on resume. - const dt = Math.min((now - last) / 1000, 0.05); + const dt = tickSeconds(now, last); last = now; bouncer.step(dt, bounds()); @@ -307,10 +337,21 @@ export function startCorners(context) { 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() { - clearInterval(timer); + clearTimeout(timer); window.removeEventListener('resize', layout); 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. */ - function drop(now) { + function drop(clock) { // 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 false; @@ -470,7 +511,7 @@ export function startTrail(context) { cell.img.style.width = `${rect.width}px`; cell.img.style.height = `${rect.height}px`; 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 }; // Re-append so the freshest still paints over the rest of the tail. layer.appendChild(cell.img); @@ -479,11 +520,11 @@ export function startTrail(context) { return true; } - function fade(now) { + function fade(clock) { for (const cell of cells) { 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) { cell.img.style.opacity = '0'; // 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); 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 stopped = false; function frame(now) { if (stopped) return; - const dt = Math.min((now - last) / 1000, 0.05); + const dt = tickSeconds(now, last); last = now; + clock += dt; const wasX = glider.x; 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 // however the frame is turning. 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); } @@ -884,9 +929,8 @@ export function startDragResize({ element }) { function frame(now) { if (stopped) return; - const dt = Math.min((now - last) / 1000, 0.05); + elapsed += tickSeconds(now, last); last = now; - elapsed += dt; const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1; current.update(p); @@ -959,14 +1003,20 @@ export function startWash({ element }) { 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 stopped = false; function frame(now) { 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)})`; // 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. @@ -1336,7 +1386,7 @@ export function startPong({ element }) { function frame(now) { if (stopped) return; - const dt = Math.min((now - last) / 1000, 0.05); + const dt = tickSeconds(now, last); last = now; // Out of play between the goal and the serve, so the score has a beat to be diff --git a/public/player/player.css b/public/player/player.css index c2ed036..f3f768f 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -1,6 +1,10 @@ :root { --accent: #ff4fc3; --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%; height: 100%; 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 posters are pale and read far brighter on a projector than on a desk. */ filter: brightness(0.45) saturate(1.15); @@ -97,7 +101,7 @@ body { 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; + transition: opacity calc(500ms * var(--motion-stretch)) ease; will-change: transform; } diff --git a/public/player/player.js b/public/player/player.js index 37604ae..9ff4b23 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -1,7 +1,14 @@ 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 { startAnimationCycle } from './motion.js'; +import { startAnimationCycle, setSpeed } from './motion.js'; import { startBackgroundCycle, showLogo } from './backdrop.js'; import { requestCapture } from './capture.js'; @@ -12,6 +19,7 @@ let state = { currentId: null, background: AUTO_BACKGROUND, animation: AUTO_ANIMATION, + lowRes: DEFAULT_LOW_RES, paused: false, }; let decks = []; @@ -22,6 +30,22 @@ let animations = null; 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 -------------------------------------------------------- // 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 previousBackground = state.background; const previousPaused = state.paused; + const previousLowRes = state.lowRes; state = msg.state; if (state.background !== previousBackground) backgrounds.setMode(state.background); 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); updateGate(); pumpProbe(); diff --git a/public/queue/index.html b/public/queue/index.html index bf763bc..af09e9f 100644 --- a/public/queue/index.html +++ b/public/queue/index.html @@ -54,6 +54,10 @@ Animation
+