From a6fd6e802a5c3107b2041b37069f3bd58dbf20b3 Mon Sep 17 00:00:00 2001 From: simon Date: Sun, 9 Aug 2026 22:34:26 +0200 Subject: [PATCH] Add a drag-and-resize animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB --- README.md | 14 +- config.js | 7 + public/player/motion.js | 384 +++++++++++++++++++++++++++++++++++++++ public/player/player.css | 31 ++++ server/index.js | 4 + 5 files changed, 436 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index af7cf5f..fcdc588 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config.js b/config.js index 6bef4e1..f8930d5 100644 --- a/config.js +++ b/config.js @@ -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 diff --git a/public/player/motion.js b/public/player/motion.js index acda571..7be79aa 100644 --- a/public/player/motion.js +++ b/public/player/motion.js @@ -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 = ``; + +// 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); diff --git a/public/player/player.css b/public/player/player.css index f2dcfe0..4caaa58 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -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; diff --git a/server/index.js b/server/index.js index c718c2e..0233c8f 100644 --- a/server/index.js +++ b/server/index.js @@ -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`,