Waiting for the shuffle bag to land on the animation you are working on makes testing one of them tedious. The queue page now carries an Animation row next to Background: Auto keeps the per-track cycle, any other button pins the player to that animation until the mode changes again. The animation names live in config.js alongside their labels, so the server can validate them and the queue page can label its buttons; motion.js keys its registry by the same names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
316 lines
9.1 KiB
JavaScript
316 lines
9.1 KiB
JavaScript
// 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,
|
||
CORNER_INTERVAL_SECONDS,
|
||
CORNER_MARGIN,
|
||
AUTO_ANIMATION,
|
||
} 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 };
|
||
}
|
||
|
||
/** 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.
|
||
*/
|
||
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 = applyFrameSize(element);
|
||
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 = 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));
|
||
}
|
||
|
||
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());
|
||
place(element, bouncer.x, bouncer.y);
|
||
handle = requestAnimationFrame(frame);
|
||
}
|
||
|
||
handle = requestAnimationFrame(frame);
|
||
|
||
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 -----------------------------------------------------------
|
||
|
||
// Keyed by the names in config.js, which is what travels over the socket.
|
||
export const ANIMATIONS = {
|
||
bounce: startBounce,
|
||
corners: startCorners,
|
||
};
|
||
|
||
const NAMES = Object.keys(ANIMATIONS);
|
||
|
||
/**
|
||
* Run one animation at a time. In auto mode next() swaps to a freshly picked
|
||
* one on every track change; pinned to a name (from the queue page, for
|
||
* testing) it stays put until the mode changes again.
|
||
*/
|
||
export function startAnimationCycle(context, mode = AUTO_ANIMATION) {
|
||
const picker = createPicker(NAMES.length);
|
||
let stop = null;
|
||
let current = null;
|
||
|
||
function run(name) {
|
||
stop?.();
|
||
current = name;
|
||
stop = ANIMATIONS[name](context);
|
||
return current;
|
||
}
|
||
|
||
function next() {
|
||
// Pinned: the track changed, but the operator asked for this one.
|
||
if (mode !== AUTO_ANIMATION) return current;
|
||
return run(NAMES[picker.next()]);
|
||
}
|
||
|
||
function setMode(value) {
|
||
mode = ANIMATIONS[value] ? value : AUTO_ANIMATION;
|
||
// Always restarts, so switching back to auto takes effect immediately
|
||
// rather than waiting out the current track.
|
||
return mode === AUTO_ANIMATION ? run(NAMES[picker.next()]) : run(mode);
|
||
}
|
||
|
||
setMode(mode);
|
||
|
||
return {
|
||
next,
|
||
setMode,
|
||
get mode() {
|
||
return mode;
|
||
},
|
||
get name() {
|
||
return current;
|
||
},
|
||
};
|
||
}
|