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`,
);
});