diff --git a/README.md b/README.md index a2a8306..81bc3a9 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ A YouTube playlist player for parties. Two browser windows run side by side: - **Player** (`http://localhost:3000/`) — fullscreen poster background with a - small video frame drifting diagonally and bouncing off the edges. Consecutive - videos overlap in a 5 second audio/video crossfade, so there is never silence - between tracks. + small video frame that moves around, switching animation on every track (see + [Animations](#animations)). Consecutive videos overlap in a 5 second + audio/video crossfade, so there is never silence between tracks. - **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to reorder, delete, and see how much playtime is left. @@ -31,8 +31,27 @@ autoplay, so the first click is what unlocks audio. One click per session. showing it while the player page is closed). Totals are prefixed with `≥` 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, port, - and the selectable backgrounds. +- Settings live in `config.js`: overlap length, frame size, drift speed, corner + screenshot interval and margin, port, and the selectable backgrounds. 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: + +- **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. + +**The corners animation asks to share your screen.** The decks are cross-origin +YouTube iframes, so nothing on the page can read their pixels — capturing the +tab and cropping out the frame is the only way to photograph what is playing. +The prompt appears once, on the first click of **Start**; choose *this tab*. +Declining is fine: the frame still parks in its corner, just without the +screenshots. The prompt is skipped entirely on a plain-HTTP origin (i.e. when +you open the player from another machine over the network), for the same reason. ## Adding animations @@ -40,3 +59,14 @@ autoplay, so the first click is what unlocks audio. One click per session. `#frameSkin` inside it is the cosmetic layer, and the two video decks sit inside that. New effects should nest a fresh wrapper rather than sharing a transform with the bounce loop. + +An animation is `start(context) => stop()`, where `context` is +`{ element, capture }` — `capture` may be null, and is read late, so an +animation must cope with it being absent. `stop()` has to undo everything: +timers, listeners, and any DOM it added, because the next animation takes over +the same element immediately. Add it to the `ANIMATIONS` array and it joins the +rotation. + +Selection uses a shuffle bag (`createPicker`): every animation comes up exactly +once per pass, so they stay evenly used, and the same one never runs twice in a +row — including across the seam between two passes. diff --git a/config.js b/config.js index 3e23a84..49a7b6c 100644 --- a/config.js +++ b/config.js @@ -9,12 +9,20 @@ export const OVERLAP_SECONDS = 5; // How fast the player switches when the user jumps around manually. export const HARD_CUT_SECONDS = 0.4; -// The video frame takes up this fraction of the screen. +// The video frame takes up this fraction of the screen. Keep this below 0.5: +// the corners animation puts a frame-sized image in all four corners, which +// only stays overlap-free while two frames fit side by side. export const FRAME_SCALE = 0.3; // Pixels per second the frame drifts across the screen. export const BOUNCE_SPEED = 90; +// The corners animation: seconds between screenshots. +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; + // Selectable fullscreen backgrounds, keyed by the id sent over the socket. export const BACKGROUNDS = { pink: { label: 'Pink', src: '/assets/PLAKAT_A2_pink_426x600mm.png' }, diff --git a/public/player/capture.js b/public/player/capture.js new file mode 100644 index 0000000..004a192 --- /dev/null +++ b/public/player/capture.js @@ -0,0 +1,94 @@ +// Screen capture, used to photograph the video frame. +// +// The decks are cross-origin YouTube iframes, so nothing on the page can read +// their pixels — canvas taints, html2canvas sees an empty box. Capturing the +// tab through getDisplayMedia and cropping out the frame's rect is the only way +// to get the actually-playing image. + +/** + * Ask for the screen-share stream. Resolves to a `{ grab, stop }` handle, or to + * null if the operator declines or the API is missing — callers degrade rather + * than fail. + * + * Must be called synchronously from a user gesture (getDisplayMedia needs + * transient activation), so kick it off before any await and keep the promise. + */ +export function requestCapture() { + // Undefined on a plain-HTTP origin, which is exactly how the README tells + // people to reach this page from other machines on the network. + if (!navigator.mediaDevices?.getDisplayMedia) return Promise.resolve(null); + + try { + return navigator.mediaDevices + .getDisplayMedia({ + // preferCurrentTab keeps the stream to this tab's viewport, so stream + // pixels map to CSS pixels by one scale factor. Picking a whole screen + // instead would put browser chrome in the frame and skew every crop. + video: { frameRate: 8 }, + audio: false, + preferCurrentTab: true, + }) + .then(attach) + .catch(() => null); + } catch { + // Some rejections arrive synchronously. Never let them escape into + // start(), which would abort playback before the gate even lifts. + return Promise.resolve(null); + } +} + +async function attach(stream) { + const [track] = stream.getVideoTracks(); + if (!track) return null; + + const video = document.createElement('video'); + video.srcObject = stream; + video.muted = true; + video.playsInline = true; + // Must be in the document, like #probe: a detached video is never composited + // and the browser throttles its frames hard, which would leave screenshots + // showing a moment several seconds stale. 1x1 and transparent, so it costs + // nothing and cannot show up in its own crop. + video.id = 'captureSink'; + document.body.appendChild(video); + await video.play().catch(() => {}); + + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + + return { + /** + * Crop a viewport rect out of the live stream. `rect` is in CSS pixels. + * Returns a data URL, or null while the stream has yet to deliver a frame. + */ + grab({ x, y, width, height }) { + if (video.readyState < 2 || !video.videoWidth) return null; + if (width <= 0 || height <= 0) return null; + + const scaleX = video.videoWidth / window.innerWidth; + const scaleY = video.videoHeight / window.innerHeight; + + canvas.width = Math.round(width * scaleX); + canvas.height = Math.round(height * scaleY); + context.drawImage( + video, + x * scaleX, + y * scaleY, + width * scaleX, + height * scaleY, + 0, + 0, + canvas.width, + canvas.height, + ); + + return canvas.toDataURL('image/jpeg', 0.85); + }, + + stop() { + for (const t of stream.getTracks()) t.stop(); + video.srcObject = null; + video.remove(); + }, + }; +} diff --git a/public/player/index.html b/public/player/index.html index fc292bb..5276511 100644 --- a/public/player/index.html +++ b/public/player/index.html @@ -9,6 +9,10 @@
+ +
+
diff --git a/public/player/motion.js b/public/player/motion.js index 289d3b2..a4df561 100644 --- a/public/player/motion.js +++ b/public/player/motion.js @@ -1,7 +1,17 @@ // 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 } from '/config.js'; +import { + FRAME_SCALE, + BOUNCE_SPEED, + CORNER_INTERVAL_SECONDS, + CORNER_MARGIN, +} from '/config.js'; const ASPECT = 9 / 16; @@ -22,6 +32,58 @@ export function frameSize(viewportWidth, viewportHeight) { 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. @@ -65,8 +127,8 @@ export function createBouncer({ x = 0, y = 0 } = {}) { return bouncer; } -export function startBounce(element) { - let size = frameSize(window.innerWidth, window.innerHeight); +export function startBounce({ element }) { + let size = applyFrameSize(element); const bouncer = createBouncer({ x: (window.innerWidth - size.width) / 2, y: (window.innerHeight - size.height) / 2, @@ -80,31 +142,152 @@ export function startBounce(element) { } function measure() { - size = frameSize(window.innerWidth, window.innerHeight); - element.style.width = `${size.width}px`; - element.style.height = `${size.height}px`; + 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)); } - measure(); 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()); - element.style.transform = `translate3d(${bouncer.x.toFixed(1)}px, ${bouncer.y.toFixed(1)}px, 0)`; - requestAnimationFrame(frame); + place(element, bouncer.x, bouncer.y); + handle = requestAnimationFrame(frame); } - requestAnimationFrame(frame); + handle = requestAnimationFrame(frame); - return bouncer; + 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, + }; +} + +/** + * 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. + */ +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); + const images = slots.map(() => { + const img = document.createElement('img'); + layer.appendChild(img); + return img; + }); + + let slot = 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); + + images.forEach((img, i) => { + const at = cornerPosition(slots[i], size, viewport, margin); + img.style.width = `${size.width}px`; + img.style.height = `${size.height}px`; + place(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 img = images[slot]; + img.src = shot; + img.classList.add('visible'); + slot = (slot + 1) % images.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 img of images) img.remove(); + }; +} + +// --- registry ----------------------------------------------------------- + +export const ANIMATIONS = [ + { name: 'bounce', start: startBounce }, + { name: 'corners', start: startCorners }, +]; + +/** + * Run one animation at a time, swapping to a freshly picked one on next(). + * Returns the handle the player drives from its track changes. + */ +export function startAnimationCycle(context) { + const picker = createPicker(ANIMATIONS.length); + let stop = null; + let current = null; + + function next() { + stop?.(); + current = ANIMATIONS[picker.next()]; + stop = current.start(context); + return current.name; + } + + next(); + + return { + next, + get name() { + return current?.name ?? null; + }, + }; } diff --git a/public/player/player.css b/public/player/player.css index 81d2bcf..f2dcfe0 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -41,12 +41,43 @@ body { opacity: 1; } +/* --- frozen screenshots ------------------------------------------------ */ + +#stills { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 1; +} + +#stills img { + position: absolute; + top: 0; + left: 0; + object-fit: cover; + opacity: 0; + /* Matches #frameSkin, so a still reads as a frozen copy of the frame. */ + border-radius: 10px; + border: 2px solid rgba(255, 255, 255, 0.85); + box-shadow: + 0 0 0 4px rgba(255, 79, 195, 0.55), + 0 24px 70px rgba(0, 0, 0, 0.7); + transition: opacity 500ms ease; + will-change: transform; +} + +#stills img.visible { + opacity: 1; +} + /* --- the moving frame -------------------------------------------------- */ #frameMover { position: fixed; top: 0; left: 0; + /* Above the stills, so the live video always wins if anything ever overlaps. */ + z-index: 2; will-change: transform; } @@ -80,6 +111,19 @@ body { pointer-events: none; } +/* The screen-capture stream's sink. Has to be in the document to keep getting + frames (see capture.js), but must not be visible or catch clicks. */ +#captureSink { + position: fixed; + left: 0; + top: 0; + width: 1px; + height: 1px; + opacity: 0.01; + pointer-events: none; + z-index: -1; +} + #probe { position: fixed; left: -400px; diff --git a/public/player/player.js b/public/player/player.js index be0354c..31b6fc9 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -1,7 +1,8 @@ import { connect } from '/shared/socket.js'; import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.js'; import { createPlayer, loadApi, STATE } from './yt.js'; -import { startBounce } from './motion.js'; +import { startAnimationCycle } from './motion.js'; +import { requestCapture } from './capture.js'; const TICK_MS = 250; @@ -10,6 +11,7 @@ let decks = []; let active = 0; let transition = null; // { until, duration, from, to } while crossfading let started = false; +let animations = null; const socket = connect({ onMessage, onStatus }); @@ -55,9 +57,24 @@ function updateGate() { async function start() { if (started) return; started = true; + + // Before any await: getDisplayMedia needs the click's transient activation, + // which is gone the moment this function yields. + const capturing = requestCapture(); + gate.classList.add('hidden'); - startBounce(document.getElementById('frameMover')); + // Mutable context: the animations start now rather than stalling behind the + // permission prompt, and read `capture` when they actually take a screenshot. + const animationContext = { + element: document.getElementById('frameMover'), + capture: null, + }; + capturing.then((capture) => { + animationContext.capture = capture; + }); + + animations = startAnimationCycle(animationContext); decks = await Promise.all([ makeDeck('deck-a', 0), @@ -230,6 +247,9 @@ function finishTransition() { decks[to].setOpacity(1); active = to; + // New track, new animation. + animations?.next(); + if (advance) socket.send({ type: 'trackEnded', id: outgoingId }); } @@ -324,7 +344,16 @@ window.__player = { const duration = deck?.player.getDuration?.() ?? 0; if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true); }, + nextAnimation() { + return animations?.next() ?? null; + }, get state() { - return { state, active, transition, decks: decks.map((d) => d.itemId) }; + return { + state, + active, + transition, + decks: decks.map((d) => d.itemId), + animation: animations?.name ?? null, + }; }, }; diff --git a/server/index.js b/server/index.js index 43ba4b7..b2e067a 100644 --- a/server/index.js +++ b/server/index.js @@ -7,7 +7,16 @@ import { fileURLToPath } from 'node:url'; import express from 'express'; import { WebSocketServer } from 'ws'; -import { PORT, OVERLAP_SECONDS, HARD_CUT_SECONDS, FRAME_SCALE, BOUNCE_SPEED, BACKGROUNDS } from '../config.js'; +import { + PORT, + OVERLAP_SECONDS, + HARD_CUT_SECONDS, + FRAME_SCALE, + BOUNCE_SPEED, + CORNER_INTERVAL_SECONDS, + CORNER_MARGIN, + BACKGROUNDS, +} from '../config.js'; import { parseVideoId, fetchTitle } from './youtube.js'; import * as store from './state.js'; @@ -32,6 +41,8 @@ app.get('/config.js', (_req, res) => { `export const HARD_CUT_SECONDS = ${HARD_CUT_SECONDS};\n` + `export const FRAME_SCALE = ${FRAME_SCALE};\n` + `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 BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n`, ); });