// 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, }; } // How many stills each corner holds, and how far apart they sit as a fraction // of the frame size. Deep enough that a corner reads as a pile, shallow enough // that the stack stays in its own quarter of the screen. const CORNER_STACK = 3; const CORNER_STACK_STEP = 0.09; /** * Top-left offset for the `depth`-th still of a corner: the same spot as the * corner itself, nudged inward (toward the middle of the screen) once per * depth. Successive stills therefore never land on top of each other — each * one leaves the edges of the ones below it showing. */ export function cornerStackPosition(index, depth, size, viewport, margin) { const at = cornerPosition(index, size, viewport, margin); const right = index === 1 || index === 2; const bottom = index === 2 || index === 3; const step = depth * CORNER_STACK_STEP; return { x: at.x + (right ? -1 : 1) * step * size.width, y: at.y + (bottom ? -1 : 1) * step * size.height, }; } /** * 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. * * Each corner stacks CORNER_STACK stills at staggered offsets, and the newest * shot always moves to the top of the layer, so a full corner still reads as * something new arriving rather than as an unchanged picture. */ 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); // Slot-major: consecutive shots move to the next corner, and the depth only // advances once every corner at the current depth has been filled. const cells = []; for (let depth = 0; depth < CORNER_STACK; depth += 1) { for (const corner of slots) { const img = document.createElement('img'); layer.appendChild(img); cells.push({ img, corner, depth }); } } let next = 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); for (const cell of cells) { const at = cornerStackPosition(cell.corner, cell.depth, size, viewport, margin); cell.img.style.width = `${size.width}px`; cell.img.style.height = `${size.height}px`; place(cell.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 cell = cells[next]; cell.img.src = shot; cell.img.classList.add('visible'); // Re-append so the freshest still paints over the rest of its pile. layer.appendChild(cell.img); next = (next + 1) % cells.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 cell of cells) cell.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; }, }; }