// 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; }, }; }