Files
party-player/public/player/motion.js
T
simonandClaude Opus 5 a6fd6e802a Add a drag-and-resize animation
A drawn pointer grabs the frame near its top edge, drags it across the
screen, lets go, then takes hold of one corner and pulls the frame to a
new size. Moves ease in and overshoot slightly before settling, and the
paths bow, so the motion reads as a hand rather than a tween.

The resize pins the opposite corner and keeps the aspect ratio locked —
a stretched video would read as broken, not as someone resizing — and
clamps the width to the room left before the corner leaves the screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:34:26 +02:00

736 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_MIN_SCALE,
DRAG_MAX_SCALE,
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();
};
}
// --- 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 = `<svg viewBox="0 0 24 24" width="27" height="27" aria-hidden="true">
<path d="M5 2.5 L5 19.6 L9.5 15.4 L12.3 21.5 L15.2 20.1 L12.4 14.2 L18.6 14 Z"
fill="#fff" stroke="#14060f" stroke-width="1.3" stroke-linejoin="round" />
</svg>`;
// 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);
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);
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,
};
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;
},
};
}