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
This commit is contained in:
simon
2026-08-09 22:34:26 +02:00
co-authored by Claude Opus 5
parent 1afe6580ae
commit a6fd6e802a
5 changed files with 436 additions and 4 deletions
+10 -4
View File
@@ -33,18 +33,24 @@ autoplay, so the first click is what unlocks audio. One click per session.
whenever a length is still unknown. whenever a length is still unknown.
- The queue is saved to `queue.json` on every change and restored at startup. - The queue is saved to `queue.json` on every change and restored at startup.
- Settings live in `config.js`: overlap length, frame size, drift speed, corner - Settings live in `config.js`: overlap length, frame size, drift speed, corner
screenshot interval and margin, port, and the selectable backgrounds and screenshot interval and margin, the drag animation's size range, port, and
animations. Anything the browser needs must also be mirrored in the the selectable backgrounds and animations. Anything the browser needs must
`/config.js` route in `server/index.js`. also be mirrored in the `/config.js` route in `server/index.js`.
## Animations ## Animations
The video frame's movement changes with every track. Two animations ship today: The video frame's movement changes with every track. Three animations ship
today:
- **bounce** — diagonal drift, reflecting off the screen edges. - **bounce** — diagonal drift, reflecting off the screen edges.
- **corners** — the frame parks in a random corner and every 5 seconds a - **corners** — the frame parks in a random corner and every 5 seconds a
screenshot of it appears in the next of the three other corners, going screenshot of it appears in the next of the three other corners, going
clockwise. The frame never moves, so a screenshot can never cover it. clockwise. The frame never moves, so a screenshot can never cover it.
- **drag** — a drawn mouse pointer travels to the frame, grabs it near its top
edge and drags it across the screen, lets go for a beat, then takes hold of
one corner and pulls the frame to a new size. The aspect ratio stays locked
and the frame stays on screen, so the resize reads as a window resize rather
than a stretch.
The queue page's **Animation** buttons pin the player to one of them, which is The queue page's **Animation** buttons pin the player to one of them, which is
how you look at a single animation without waiting for the cycle to reach it. how you look at a single animation without waiting for the cycle to reach it.
+7
View File
@@ -23,11 +23,18 @@ export const CORNER_INTERVAL_SECONDS = 5;
// Gap between the frame and the screen edge, as a fraction of the short side. // Gap between the frame and the screen edge, as a fraction of the short side.
export const CORNER_MARGIN = 0.025; export const CORNER_MARGIN = 0.025;
// The drag animation resizes the frame to somewhere between these multiples of
// the normal frame size. Above 1 the frame grows past FRAME_SCALE, which is
// fine — nothing else is on screen while this animation runs.
export const DRAG_MIN_SCALE = 0.55;
export const DRAG_MAX_SCALE = 1.6;
// The frame animations, keyed by the id sent over the socket. The names must // The frame animations, keyed by the id sent over the socket. The names must
// match the registry in public/player/motion.js, which supplies the code. // match the registry in public/player/motion.js, which supplies the code.
export const ANIMATIONS = { export const ANIMATIONS = {
bounce: { label: 'Bounce' }, bounce: { label: 'Bounce' },
corners: { label: 'Corners' }, corners: { label: 'Corners' },
drag: { label: 'Drag & resize' },
}; };
// Cycle a fresh random animation on every track change. Anything else pins the // Cycle a fresh random animation on every track change. Anything else pins the
+384
View File
@@ -11,6 +11,8 @@ import {
BOUNCE_SPEED, BOUNCE_SPEED,
CORNER_INTERVAL_SECONDS, CORNER_INTERVAL_SECONDS,
CORNER_MARGIN, CORNER_MARGIN,
DRAG_MIN_SCALE,
DRAG_MAX_SCALE,
AUTO_ANIMATION, AUTO_ANIMATION,
} from '/config.js'; } from '/config.js';
@@ -296,12 +298,394 @@ export function startCorners(context) {
}; };
} }
// --- 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 ----------------------------------------------------------- // --- registry -----------------------------------------------------------
// Keyed by the names in config.js, which is what travels over the socket. // Keyed by the names in config.js, which is what travels over the socket.
export const ANIMATIONS = { export const ANIMATIONS = {
bounce: startBounce, bounce: startBounce,
corners: startCorners, corners: startCorners,
drag: startDragResize,
}; };
const NAMES = Object.keys(ANIMATIONS); const NAMES = Object.keys(ANIMATIONS);
+31
View File
@@ -94,6 +94,37 @@ body {
background: #000; background: #000;
} }
/* Lift the frame while the drag animation is holding it, the way a dragged
window separates from the desktop. */
#frameMover.dragging #frameSkin {
box-shadow:
0 0 0 4px rgba(255, 79, 195, 0.75),
0 40px 100px rgba(0, 0, 0, 0.85);
}
/* --- the drawn pointer -------------------------------------------------- */
/* Built and removed by the drag animation; nothing else on the page has one. */
#pointer {
position: fixed;
top: 0;
left: 0;
z-index: 3;
pointer-events: none;
will-change: transform;
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.6));
}
#pointer svg {
display: block;
transition: transform 90ms ease;
}
/* The click itself: the arrow dips, so a grab is visible even mid-pause. */
#pointer.down svg {
transform: scale(0.82);
}
.deck { .deck {
position: absolute; position: absolute;
inset: 0; inset: 0;
+4
View File
@@ -15,6 +15,8 @@ import {
BOUNCE_SPEED, BOUNCE_SPEED,
CORNER_INTERVAL_SECONDS, CORNER_INTERVAL_SECONDS,
CORNER_MARGIN, CORNER_MARGIN,
DRAG_MIN_SCALE,
DRAG_MAX_SCALE,
BACKGROUNDS, BACKGROUNDS,
ANIMATIONS, ANIMATIONS,
AUTO_ANIMATION, AUTO_ANIMATION,
@@ -45,6 +47,8 @@ app.get('/config.js', (_req, res) => {
`export const BOUNCE_SPEED = ${BOUNCE_SPEED};\n` + `export const BOUNCE_SPEED = ${BOUNCE_SPEED};\n` +
`export const CORNER_INTERVAL_SECONDS = ${CORNER_INTERVAL_SECONDS};\n` + `export const CORNER_INTERVAL_SECONDS = ${CORNER_INTERVAL_SECONDS};\n` +
`export const CORNER_MARGIN = ${CORNER_MARGIN};\n` + `export const CORNER_MARGIN = ${CORNER_MARGIN};\n` +
`export const DRAG_MIN_SCALE = ${DRAG_MIN_SCALE};\n` +
`export const DRAG_MAX_SCALE = ${DRAG_MAX_SCALE};\n` +
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` + `export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` +
`export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` + `export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` +
`export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`, `export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`,