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.
- 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
screenshot interval and margin, port, and the selectable backgrounds and
animations. Anything the browser needs must also be mirrored in the
`/config.js` route in `server/index.js`.
screenshot interval and margin, the drag animation's size range, port, and
the selectable backgrounds and animations. Anything the browser needs must
also be mirrored in the `/config.js` route in `server/index.js`.
## 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.
- **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
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
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.
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
// match the registry in public/player/motion.js, which supplies the code.
export const ANIMATIONS = {
bounce: { label: 'Bounce' },
corners: { label: 'Corners' },
drag: { label: 'Drag & resize' },
};
// Cycle a fresh random animation on every track change. Anything else pins the
+384
View File
@@ -11,6 +11,8 @@ import {
BOUNCE_SPEED,
CORNER_INTERVAL_SECONDS,
CORNER_MARGIN,
DRAG_MIN_SCALE,
DRAG_MAX_SCALE,
AUTO_ANIMATION,
} 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 -----------------------------------------------------------
// 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);
+31
View File
@@ -94,6 +94,37 @@ body {
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 {
position: absolute;
inset: 0;
+4
View File
@@ -15,6 +15,8 @@ import {
BOUNCE_SPEED,
CORNER_INTERVAL_SECONDS,
CORNER_MARGIN,
DRAG_MIN_SCALE,
DRAG_MAX_SCALE,
BACKGROUNDS,
ANIMATIONS,
AUTO_ANIMATION,
@@ -45,6 +47,8 @@ app.get('/config.js', (_req, res) => {
`export const BOUNCE_SPEED = ${BOUNCE_SPEED};\n` +
`export const CORNER_INTERVAL_SECONDS = ${CORNER_INTERVAL_SECONDS};\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 ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` +
`export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`,