// 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, DRAG_FRAME_SCALE, DRAG_MIN_SCALE, DRAG_MAX_SCALE, TRAIL_SPEED, TRAIL_TURN_RATE, TRAIL_SPACING, TRAIL_LENGTH, TRAIL_FADE_SECONDS, AUTO_ANIMATION, } from '/config.js'; const ASPECT = 9 / 16; /** * 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. */ export function frameSize(viewportWidth, viewportHeight, scale = FRAME_SCALE) { let width = viewportWidth * scale; let height = width * ASPECT; const maxHeight = viewportHeight * 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(); }; } // --- trail -------------------------------------------------------------- // The frame glides along a curving path and drops a photograph of itself every // so often. Each still stays where it was taken and fades, so the live video // reads as the head of a comet whose tail is its own recent past. /** * Steering toward a waypoint at constant speed, with a cap on how fast the * heading may change. The cap is the whole point: an instant turn would lay the * stills out in straight dotted lines, and it is the curve that makes a tail * read as one. Pure apart from the injected RNG, so the path can be exercised * without a browser. */ export function createGlider({ x = 0, y = 0 } = {}, random = Math.random) { // How close counts as arrived. Anything tighter than the turn radius // (speed / turn rate) risks the glider circling a waypoint forever, unable to // steer hard enough to land on it. const reach = TRAIL_SPEED / TRAIL_TURN_RATE; function normalise(angle) { return Math.atan2(Math.sin(angle), Math.cos(angle)); } const glider = { x, y, heading: random() * Math.PI * 2, target: null, retarget(bounds) { glider.target = { x: random() * Math.max(0, bounds.width), y: random() * Math.max(0, bounds.height), }; return glider.target; }, step(dt, bounds) { const maxX = Math.max(0, bounds.width); const maxY = Math.max(0, bounds.height); if (!glider.target) glider.retarget(bounds); if (Math.hypot(glider.target.x - glider.x, glider.target.y - glider.y) < reach) { glider.retarget(bounds); } // Shortest way round to the bearing of the waypoint, then take at most // this step's share of the turn rate. const bearing = Math.atan2(glider.target.y - glider.y, glider.target.x - glider.x); const turn = normalise(bearing - glider.heading); glider.heading = normalise( glider.heading + clamp(turn, -TRAIL_TURN_RATE * dt, TRAIL_TURN_RATE * dt), ); glider.x += Math.cos(glider.heading) * TRAIL_SPEED * dt; glider.y += Math.sin(glider.heading) * TRAIL_SPEED * dt; // Reflect off the edges rather than clamping and sliding along them. The // waypoint is always inside the bounds, so steering picks up from there // and pulls the glider back inward instead of straight into the wall. if (glider.x < 0 || glider.x > maxX) { glider.x = clamp(glider.x, 0, maxX); glider.heading = normalise(Math.PI - glider.heading); } if (glider.y < 0 || glider.y > maxY) { glider.y = clamp(glider.y, 0, maxY); glider.heading = normalise(-glider.heading); } return glider; }, }; return glider; } export function startTrail(context) { const { element } = context; const layer = document.getElementById('stills'); let size = applyFrameSize(element); const glider = createGlider({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2, }); // A ring buffer: the tail is a fixed number of images, and the oldest one is // what the next drop reuses. `rect` doubles as the "currently showing" flag. const cells = []; for (let i = 0; i < TRAIL_LENGTH; i += 1) { const img = document.createElement('img'); img.classList.add('trail'); layer.appendChild(img); cells.push({ img, born: 0, rect: null }); } let next = 0; let travelled = 0; function bounds() { return { width: window.innerWidth - size.width, height: window.innerHeight - size.height, }; } function measure() { size = applyFrameSize(element); const limit = bounds(); glider.x = Math.min(glider.x, Math.max(0, limit.width)); glider.y = Math.min(glider.y, Math.max(0, limit.height)); } /** True when the frame overlaps nothing that is still showing in the tail. */ function clearOfTail(rect) { for (const cell of cells) { const other = cell.rect; if (!other) continue; if ( rect.left < other.right && rect.right > other.left && rect.top < other.bottom && rect.bottom > other.top ) { return false; } } return true; } /** Photograph the frame where it stands. Reports whether a still landed. */ function drop(now) { // 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; const rect = element.getBoundingClientRect(); // The capture is a crop of the tab, so an older still lying under the frame // would be photographed into the new one. Skip this drop instead; the // travelled distance is not spent, so it happens as soon as the frame is // back in the clear. if (!clearOfTail(rect)) return false; const shot = context.capture.grab(rect); if (!shot) return false; const cell = cells[next]; cell.img.src = shot; 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.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); next = (next + 1) % cells.length; return true; } function fade(now) { for (const cell of cells) { if (!cell.rect) continue; const life = 1 - (now - cell.born) / 1000 / TRAIL_FADE_SECONDS; if (life <= 0) { cell.img.style.opacity = '0'; // Gone, so it stops holding up drops that pass over its old spot. cell.rect = null; continue; } // Squared: a still holds its brightness for a moment and then goes // quickly, which thins the tail out instead of trailing a long grey smear. cell.img.style.opacity = (life * life).toFixed(3); } } place(element, glider.x, glider.y); window.addEventListener('resize', measure); let last = performance.now(); let handle = 0; let stopped = false; function frame(now) { if (stopped) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; const wasX = glider.x; const wasY = glider.y; glider.step(dt, bounds()); place(element, glider.x, glider.y); // 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; fade(now); handle = requestAnimationFrame(frame); } handle = requestAnimationFrame(frame); return function stop() { stopped = true; cancelAnimationFrame(handle); window.removeEventListener('resize', measure); for (const cell of cells) cell.img.remove(); }; } // --- drag and resize ---------------------------------------------------- // Someone shoving a video window around a desktop: a pointer travels to the // frame, grabs it, drags it somewhere else, lets go for a moment, then takes // hold of one corner and pulls the frame to a new size. The pointer is drawn, // so every move on screen has a visible cause. const ARROW = ``; // Where on the frame the pointer takes hold, as a fraction of its height — // the top strip, where a window's title bar would be. const GRAB_BAND = [0.06, 0.3]; // Seconds the pointer rests between grabs, and how far it drifts while resting. const PAUSE = [0.5, 1.7]; const IDLE_DRIFT = 2.5; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function between(min, max) { return min + Math.random() * (max - min); } /** Smoothstep: no jerk at either end, which is what an arm does. */ function smooth(t) { return t * t * (3 - 2 * t); } /** * Progress along a hand-driven move: a gentle launch, then a slight overshoot * that pulls back onto the target. `overshoot` 0 is a plain smooth ramp; the * bigger it gets, the further past the target the move swings first. */ export function settle(t, overshoot = 0.6) { const back = smooth(clamp(t, 0, 1)) - 1; return 1 + (overshoot + 1) * back ** 3 + overshoot * back ** 2; } /** * Pointer position at `t` (0..1) on its way from `from` to `to`. `curve` bows * the path sideways — a hand does not move in a straight line — and its sign * decides which way the arc leans. */ export function pointerAt(from, to, t, { curve = 0, overshoot = 0.6 } = {}) { const dx = to.x - from.x; const dy = to.y - from.y; const p = settle(t, overshoot); const bow = Math.sin(Math.PI * clamp(t, 0, 1)) * curve; return { x: from.x + dx * p - dy * bow, y: from.y + dy * p + dx * bow, }; } /** * The rect you get by dragging `corner` (0 = top left, clockwise) to `pointer` * while the opposite corner stays pinned, the way a window resize works. * * The aspect ratio is locked — a squashed video reads as broken rather than as * someone resizing — so the axis the pointer pulled furthest wins, and the * width is clamped into `min`..`max`. */ export function resizeRect(rect, corner, pointer, { min, max }) { const anchor = anchorOf(rect, corner); const pulled = Math.max( Math.abs(pointer.x - anchor.x), Math.abs(pointer.y - anchor.y) / ASPECT, ); return rectFromWidth(rect, corner, clamp(pulled, min, max)); } /** The corner that stays put while `corner` is dragged. */ function anchorOf(rect, corner) { const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; return { x: right ? rect.x : rect.x + rect.width, y: bottom ? rect.y : rect.y + rect.height, }; } /** Grow `rect` to `width` about its anchor, keeping the aspect ratio. */ function rectFromWidth(rect, corner, width) { const anchor = anchorOf(rect, corner); const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; const height = width * ASPECT; return { x: right ? anchor.x : anchor.x - width, y: bottom ? anchor.y : anchor.y - height, width, height, }; } /** Screen position of one corner of a rect, same indexing as resizeRect. */ function cornerOf(rect, corner) { const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; return { x: right ? rect.x + rect.width : rect.x, y: bottom ? rect.y + rect.height : rect.y, }; } function distance(a, b) { return Math.hypot(b.x - a.x, b.y - a.y); } /** Longer trips take longer, but not proportionally — Fitts, roughly. */ function reachTime(px) { return clamp(0.28 + px / 1500, 0.28, 1.2); } /** Dragging is slower than moving an empty hand, and much slower than a flick. */ function dragTime(px) { return clamp(0.55 + px / 700, 0.55, 2.4); } export function startDragResize({ element }) { const pointer = document.createElement('div'); pointer.id = 'pointer'; pointer.innerHTML = ARROW; document.body.appendChild(pointer); let base = frameSize(window.innerWidth, window.innerHeight, DRAG_FRAME_SCALE); let rect = { x: (window.innerWidth - base.width) / 2, y: (window.innerHeight - base.height) / 2, width: base.width, height: base.height, }; // The pointer starts off screen and slides in for its first grab. let at = { x: -40, y: window.innerHeight * 0.75 }; function margin() { return Math.min(window.innerWidth, window.innerHeight) * CORNER_MARGIN; } /** Widths the frame may take: never wider than the screen allows. */ function widthLimits() { const fit = Math.min( window.innerWidth - 2 * margin(), (window.innerHeight - 2 * margin()) / ASPECT, ); const min = Math.min(base.width * DRAG_MIN_SCALE, fit); return { min, max: clamp(base.width * DRAG_MAX_SCALE, min, fit) }; } function draw() { element.style.width = `${rect.width.toFixed(1)}px`; element.style.height = `${rect.height.toFixed(1)}px`; place(element, rect.x, rect.y); place(pointer, at.x, at.y); } // --- the script --- // // One cycle is a fixed sequence of beats; each beat gets its endpoints when // it starts, so a viewport resize part-way through never strands the pointer // at coordinates that no longer exist. let steps = []; let index = 0; let elapsed = 0; // Filled in by the press beats and read by the drag beats that follow. let held = { x: 0, y: 0 }; let corner = 0; let anchored = rect; function travel(to, options) { const from = at; const target = to(); return { duration: options.time(distance(from, target)), update(p) { at = pointerAt(from, target, p, options); options.moved?.(); }, }; } /** Grab: the pointer lands on the frame's title-bar strip. */ function reachFrame() { return travel( () => ({ x: rect.x + between(0.2, 0.8) * rect.width, y: rect.y + between(GRAB_BAND[0], GRAB_BAND[1]) * rect.height, }), // An empty hand shoots past the target and pulls back onto it. { time: reachTime, curve: between(-0.07, 0.07), overshoot: 1.6 }, ); } function reachCorner() { corner = Math.floor(Math.random() * CORNERS); return travel(() => cornerOf(rect, corner), { time: reachTime, curve: between(-0.05, 0.05), overshoot: 1.4, }); } function press() { return { duration: between(0.1, 0.22), begin() { pointer.classList.add('down'); element.classList.add('dragging'); held = { x: at.x - rect.x, y: at.y - rect.y }; anchored = { ...rect }; }, update() {}, }; } function release() { const from = at; return { duration: between(PAUSE[0], PAUSE[1]), begin() { pointer.classList.remove('down'); element.classList.remove('dragging'); }, // A resting hand is never quite still. update(p) { at = { x: from.x + Math.sin(p * 7) * IDLE_DRIFT, y: from.y + Math.sin(p * 5 + 1) * IDLE_DRIFT * 0.7, }; }, }; } /** Drag: the frame hangs off the pointer at the offset it was grabbed by. */ function dragFrame() { const gap = margin(); return travel( () => { const limitX = Math.max(gap, window.innerWidth - rect.width - gap); const limitY = Math.max(gap, window.innerHeight - rect.height - gap); return { x: between(gap, limitX) + held.x, y: between(gap, limitY) + held.y, }; }, { time: dragTime, curve: between(-0.04, 0.04), // A loaded hand overshoots less than an empty one. overshoot: 0.8, moved() { rect.x = at.x - held.x; rect.y = at.y - held.y; }, }, ); } /** Resize: the held corner follows the pointer, the opposite one stays put. */ function dragCorner() { const limits = widthLimits(); const gap = margin(); return travel( () => { // How far the corner can travel before it leaves the screen — the same // wall a real hand would run the window into. The anchor never moves, // so the room is measured from there. const anchor = anchorOf(anchored, corner); const right = corner === 1 || corner === 2; const bottom = corner === 2 || corner === 3; const room = Math.min( right ? window.innerWidth - gap - anchor.x : anchor.x - gap, (bottom ? window.innerHeight - gap - anchor.y : anchor.y - gap) / ASPECT, ); const width = clamp( between(limits.min, limits.max), limits.min, Math.max(limits.min, room), ); return cornerOf(rectFromWidth(anchored, corner, width), corner); }, { time: dragTime, curve: between(-0.03, 0.03), // Least of all when pulling against a corner: the size lands and stays. overshoot: 0.5, moved() { rect = resizeRect(anchored, corner, at, limits); }, }, ); } // Beats are built lazily: each one reads the frame where the previous one // left it. const CYCLE = [reachFrame, press, dragFrame, release, reachCorner, press, dragCorner, release]; function step() { const build = CYCLE[index % CYCLE.length]; index += 1; const current = build(); current.begin?.(); return current; } let current = step(); function measure() { base = frameSize(window.innerWidth, window.innerHeight, DRAG_FRAME_SCALE); const gap = margin(); const limits = widthLimits(); rect.width = clamp(rect.width, limits.min, limits.max); rect.height = rect.width * ASPECT; rect.x = clamp(rect.x, gap, Math.max(gap, window.innerWidth - rect.width - gap)); rect.y = clamp(rect.y, gap, Math.max(gap, window.innerHeight - rect.height - gap)); // Whatever the pointer was aiming at is stale now; start the next beat // from where things actually are. pointer.classList.remove('down'); element.classList.remove('dragging'); elapsed = 0; current = step(); } window.addEventListener('resize', measure); let last = performance.now(); let handle = 0; let stopped = false; function frame(now) { if (stopped) return; const dt = Math.min((now - last) / 1000, 0.05); last = now; elapsed += dt; const p = current.duration > 0 ? Math.min(elapsed / current.duration, 1) : 1; current.update(p); draw(); if (p >= 1) { elapsed = 0; current = step(); } handle = requestAnimationFrame(frame); } draw(); handle = requestAnimationFrame(frame); return function stop() { stopped = true; cancelAnimationFrame(handle); window.removeEventListener('resize', measure); element.classList.remove('dragging'); pointer.remove(); // The next animation sizes the frame itself, but leaving a stretched frame // up for even one paint would flash. applyFrameSize(element); }; } // --- registry ----------------------------------------------------------- // Keyed by the names in config.js, which is what travels over the socket. export const ANIMATIONS = { bounce: startBounce, corners: startCorners, drag: startDragResize, trail: startTrail, }; 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; }, }; }