diff --git a/README.md b/README.md index 852c05d..7db1e43 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,11 @@ 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 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. + small video frame that moves around. Both the animation (see + [Animations](#animations)) and the poster behind it (see + [Backgrounds](#backgrounds)) change on every track. 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, see how much playtime is left, and pick the background and animation. @@ -33,9 +35,32 @@ 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, 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`. + screenshot interval and margin, the drag animation's size range, the + background zoom range, port, and the animation list. Everything there except + `PORT` is mirrored to the browser by the generated `/config.js` route in + `server/index.js`, so a new constant needs no second list. + +## Backgrounds + +Every image file in `assets/` is a background — drop one in, restart, and it +joins the rotation with a button on the queue page named after the file. There +is no list to maintain. + +Like the animations, the background changes on every track: a different poster, +at a random zoom between 1.15× and 1.9×, cropped around a randomly chosen point +of it, cross-fading over about a second. The posters are portrait prints and +the screen is wide, so the crop is mostly a question of which band of the +poster to keep — the zoom never goes below 1.15×, because at 1× a wide screen +shows the poster's full width, paper margin included. + +Each crop is resized off the main thread and drawn into a screen-sized canvas +(`public/player/backdrop.js`), so the cross-fade never has to composite a +17-megapixel layer, and the full-size scans are never held in memory. This is +why `assets/` can hold 300 dpi print files without the player caring. + +The queue page's **Background** buttons pin one poster the same way the +animation buttons pin one animation; **Auto** hands selection back to the same +shuffle bag the animations use. ## Animations diff --git a/config.js b/config.js index f3e0a14..f301229 100644 --- a/config.js +++ b/config.js @@ -1,5 +1,9 @@ // Central knobs for the party player. +import { readdirSync } from 'node:fs'; +import { dirname, extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + export const PORT = Number(process.env.PORT) || 3000; // Seconds of overlap between the outgoing and incoming video. @@ -104,10 +108,56 @@ export const ANIMATIONS = { export const AUTO_ANIMATION = 'auto'; export const DEFAULT_ANIMATION = AUTO_ANIMATION; -// Selectable fullscreen backgrounds, keyed by the id sent over the socket. -export const BACKGROUNDS = { - pink: { label: 'Pink', src: '/assets/PLAKAT_A2_pink_426x600mm.png' }, - plain: { label: 'Classic', src: '/assets/PLAKAT_A2_426x600mm.png' }, -}; +// Selectable fullscreen backgrounds: whatever images sit in assets/, keyed by +// filename. Read from the directory rather than listed here so that dropping a +// poster in is all it takes — the list travels to both frontends over the +// generated /config.js. +const ASSET_DIR = join(dirname(fileURLToPath(import.meta.url)), 'assets'); +const IMAGE_TYPES = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']); -export const DEFAULT_BACKGROUND = 'pink'; +function readBackgrounds() { + let files; + try { + files = readdirSync(ASSET_DIR); + } catch { + return {}; + } + + return Object.fromEntries( + files + .filter((file) => IMAGE_TYPES.has(extname(file).toLowerCase())) + .sort() + .map((file) => { + const key = file.slice(0, -extname(file).length); + const label = key.charAt(0).toUpperCase() + key.slice(1); + return [key, { label, src: `/assets/${encodeURIComponent(file)}` }]; + }), + ); +} + +export const BACKGROUNDS = readBackgrounds(); + +// Cycle a fresh random background — and a fresh crop of it — on every track +// change, the way the animations rotate. Anything else pins the player to that +// one image, which is what the queue page's buttons are for. +export const AUTO_BACKGROUND = 'auto'; +export const DEFAULT_BACKGROUND = AUTO_BACKGROUND; + +// How far into a background a crop may zoom. The posters are portrait and the +// screen is wide, so a crop is mostly a question of which horizontal band of +// the poster to keep. The minimum sits above 1 because at exactly 1 a wide +// screen shows the poster's full width — paper margin and all, which reads as +// a grey stripe down each side. +export const BACKGROUND_MIN_ZOOM = 1.15; +export const BACKGROUND_MAX_ZOOM = 1.9; + +// How much of the image the focal point may sit away from the centre, as a +// fraction of the full 0–100% range. Kept below 1 because a focal point right +// on an edge crops to the poster's margin, which is just empty paper. +export const BACKGROUND_FOCUS_SPREAD = 0.7; + +// How many source pixels a crop may carry per screen pixel. The posters are +// 300 dpi print scans — far more detail than any screen shows — and the whole +// point of resizing them before drawing is to keep the crossfade cheap. A +// little above 1:1 keeps a steep zoom from looking soft. +export const BACKGROUND_MAX_OVERSAMPLE = 1.3; diff --git a/public/player/backdrop.js b/public/player/backdrop.js new file mode 100644 index 0000000..f045d17 --- /dev/null +++ b/public/player/backdrop.js @@ -0,0 +1,209 @@ +// The fullscreen poster behind everything else. Like the animations, the +// background rotates on every track change: a different poster, shown at a +// different zoom and cropped around a different point of it. +// +// The posters are portrait print scans and the screen is wide, so most of each +// image is thrown away before it is ever shown. Rather than hand the browser a +// 17-megapixel layer and let CSS crop it — which costs a texture upload per +// poster and stalls the compositor for seconds mid-crossfade — each crop is +// resized off the main thread and drawn once into a canvas the size of the +// screen. The fade then happens between two screen-sized layers, which is +// something the compositor can do without noticing. + +import { + BACKGROUNDS, + AUTO_BACKGROUND, + BACKGROUND_MIN_ZOOM, + BACKGROUND_MAX_ZOOM, + BACKGROUND_FOCUS_SPREAD, + BACKGROUND_MAX_OVERSAMPLE, +} from '/config.js'; +import { createPicker } from './motion.js'; + +const KEYS = Object.keys(BACKGROUNDS); + +/** + * A zoom level and the point of the image the crop closes in on, in percent. + * The focal point is pulled toward the middle by BACKGROUND_FOCUS_SPREAD, so a + * crop never lands on the poster's blank margin. Pure apart from the RNG. + */ +export function randomFraming(random = Math.random) { + const axis = () => 50 + (random() - 0.5) * 100 * BACKGROUND_FOCUS_SPREAD; + + return { + zoom: BACKGROUND_MIN_ZOOM + random() * (BACKGROUND_MAX_ZOOM - BACKGROUND_MIN_ZOOM), + x: axis(), + y: axis(), + }; +} + +/** + * The rectangle of the source image that ends up on screen. This is what + * `object-fit: cover` plus `object-position` would have picked, zoomed in on + * the focal point: the crop keeps the screen's aspect ratio, is as large as it + * can be at this zoom, and slides within the image so that the focal point of + * the crop sits over the same point of the image. + */ +export function cropRect(image, screen, { zoom, x, y }) { + const cover = Math.max(screen.width / image.width, screen.height / image.height); + const scale = cover * zoom; + + // Never ask for more than the image has: at zoom 1 the crop is exactly the + // covering rectangle, so the clamp only ever bites on rounding. + const width = Math.min(screen.width / scale, image.width); + const height = Math.min(screen.height / scale, image.height); + + return { + x: (image.width - width) * (x / 100), + y: (image.height - height) * (y / 100), + width, + height, + }; +} + +/** + * Load a poster already resized to the resolution this crop needs. Decoding + * and resizing both happen off the main thread; the full-size bitmap is never + * held anywhere we can see it. + */ +async function loadCrop(source, screen, framing) { + const response = await fetch(source); + if (!response.ok) throw new Error(`background ${source}: ${response.status}`); + const blob = await response.blob(); + + const probe = await createImageBitmap(blob); + const crop = cropRect(probe, screen, framing); + + // The crop is drawn at screen width, so anything beyond that many pixels + // across the crop is detail nobody can see. Sampling a little above 1:1 + // keeps a steep zoom from going soft; far above it is only work. + const wanted = Math.min( + probe.width, + Math.ceil((screen.width / crop.width) * probe.width * BACKGROUND_MAX_OVERSAMPLE), + ); + + if (wanted >= probe.width) return { bitmap: probe, crop }; + + const resized = await createImageBitmap(probe, { + resizeWidth: wanted, + resizeQuality: 'high', + }); + // Measured rather than assumed: a browser that ignores the resize options + // hands back the full-size bitmap, and the crop has to match what it got. + const ratio = resized.width / probe.width; + probe.close(); + + return { + bitmap: resized, + crop: { + x: crop.x * ratio, + y: crop.y * ratio, + width: crop.width * ratio, + height: crop.height * ratio, + }, + }; +} + +/** + * Two stacked canvases that cross-fade into each other. Only two exist however + * many posters there are, and neither holds a decoded poster once it has been + * drawn — the pixels on screen are all that is kept. + */ +export function startBackgroundCycle(layer, mode = AUTO_BACKGROUND) { + const canvases = [0, 1].map(() => { + const canvas = document.createElement('canvas'); + layer.appendChild(canvas); + return canvas; + }); + + const picker = createPicker(KEYS.length); + let front = 0; + let current = null; + let framing = null; + // Guards against a slow load landing after a newer pick has been made — a + // track change during the load would otherwise fade in the older poster. + let token = 0; + + function screenSize() { + // Capped: a 4K screen at devicePixelRatio 2 would ask for an 8K canvas to + // show a poster that was never that sharp to begin with. + const dpr = Math.min(window.devicePixelRatio || 1, 2); + return { + width: Math.max(1, Math.round(window.innerWidth * dpr)), + height: Math.max(1, Math.round(window.innerHeight * dpr)), + }; + } + + async function draw(canvas, key, wanted) { + const screen = screenSize(); + const { bitmap, crop } = await loadCrop(BACKGROUNDS[key].src, screen, wanted); + + canvas.width = screen.width; + canvas.height = screen.height; + canvas + .getContext('2d') + .drawImage(bitmap, crop.x, crop.y, crop.width, crop.height, 0, 0, screen.width, screen.height); + bitmap.close(); + } + + async function show(key, wanted = randomFraming()) { + if (!key || !BACKGROUNDS[key]) return null; + + const mine = ++token; + const back = canvases[1 - front]; + + try { + await draw(back, key, wanted); + } catch { + // Missing or unreadable file: leave whatever is up there rather than + // fading to a blank screen. + return current; + } + if (mine !== token) return current; + + back.classList.add('active'); + canvases[front].classList.remove('active'); + front = 1 - front; + current = key; + framing = wanted; + return current; + } + + // A resize changes the crop's aspect ratio, so the poster has to be drawn + // again. Debounced: dragging a window edge would otherwise start a load per + // frame, and on the projector this fires once, at fullscreen. + let resizeTimer = 0; + window.addEventListener('resize', () => { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(() => { + if (!current) return; + // Redrawn in place: the same poster at the same crop, so there is + // nothing to fade between. + draw(canvases[front], current, framing).catch(() => {}); + }, 250); + }); + + function next() { + // Pinned: the track changed, but the operator asked for this one. + if (mode !== AUTO_BACKGROUND) return current; + return show(KEYS[picker.next()]); + } + + function setMode(value) { + mode = BACKGROUNDS[value] ? value : AUTO_BACKGROUND; + return mode === AUTO_BACKGROUND ? show(KEYS[picker.next()]) : show(mode); + } + + setMode(mode); + + return { + next, + setMode, + get mode() { + return mode; + }, + get key() { + return current; + }, + }; +} diff --git a/public/player/player.css b/public/player/player.css index 3f85ae1..608140f 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -24,12 +24,13 @@ body { inset: 0; } -#backgrounds img { +/* Two canvases, cross-faded. The zoom and the crop are baked into the pixels + by backdrop.js, so these are always exactly screen-sized. */ +#backgrounds canvas { position: absolute; inset: 0; width: 100%; height: 100%; - object-fit: cover; opacity: 0; transition: opacity 900ms ease; /* Knock the poster back so the video frame stays the focal point — the @@ -37,7 +38,7 @@ body { filter: brightness(0.45) saturate(1.15); } -#backgrounds img.active { +#backgrounds canvas.active { opacity: 1; } diff --git a/public/player/player.js b/public/player/player.js index fbb26c8..cf7cc9a 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -1,12 +1,13 @@ import { connect } from '/shared/socket.js'; -import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS, AUTO_ANIMATION } from '/config.js'; +import { OVERLAP_SECONDS, HARD_CUT_SECONDS, AUTO_BACKGROUND, AUTO_ANIMATION } from '/config.js'; import { createPlayer, loadApi, STATE } from './yt.js'; import { startAnimationCycle } from './motion.js'; +import { startBackgroundCycle } from './backdrop.js'; import { requestCapture } from './capture.js'; const TICK_MS = 250; -let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION }; +let state = { items: [], currentId: null, background: AUTO_BACKGROUND, animation: AUTO_ANIMATION }; let decks = []; let active = 0; let transition = null; // { until, duration, from, to } while crossfading @@ -17,22 +18,12 @@ const socket = connect({ onMessage, onStatus }); // --- backgrounds -------------------------------------------------------- -const backgroundLayer = document.getElementById('backgrounds'); -const backgroundImages = new Map(); - -for (const [key, { src }] of Object.entries(BACKGROUNDS)) { - const img = document.createElement('img'); - img.src = src; - img.alt = ''; - backgroundLayer.appendChild(img); - backgroundImages.set(key, img); -} - -function applyBackground(key) { - for (const [name, img] of backgroundImages) img.classList.toggle('active', name === key); -} - -applyBackground(state.background); +// Runs from page load, not from the start gate: the poster is what is on +// screen while the party waits for someone to click Start. +const backgrounds = startBackgroundCycle( + document.getElementById('backgrounds'), + state.background, +); // --- start gate --------------------------------------------------------- @@ -247,8 +238,9 @@ function finishTransition() { decks[to].setOpacity(1); active = to; - // New track, new animation. + // New track, new animation, new poster and crop. animations?.next(); + backgrounds.next(); if (advance) socket.send({ type: 'trackEnded', id: outgoingId }); } @@ -326,8 +318,9 @@ function onMessage(msg) { if (msg.type !== 'state') return; const previousAnimation = state.animation; + const previousBackground = state.background; state = msg.state; - applyBackground(state.background); + if (state.background !== previousBackground) backgrounds.setMode(state.background); if (state.animation !== previousAnimation) animations?.setMode(state.animation); updateGate(); pumpProbe(); @@ -349,6 +342,9 @@ window.__player = { nextAnimation() { return animations?.next() ?? null; }, + nextBackground() { + return backgrounds.next(); + }, get state() { return { state, @@ -356,6 +352,7 @@ window.__player = { transition, decks: decks.map((d) => d.itemId), animation: animations?.name ?? null, + background: backgrounds.key, }; }, }; diff --git a/public/queue/queue.js b/public/queue/queue.js index f1673d2..d76a6e0 100644 --- a/public/queue/queue.js +++ b/public/queue/queue.js @@ -1,7 +1,7 @@ import { connect, formatDuration } from '/shared/socket.js'; -import { BACKGROUNDS, ANIMATIONS, AUTO_ANIMATION } from '/config.js'; +import { BACKGROUNDS, AUTO_BACKGROUND, ANIMATIONS, AUTO_ANIMATION } from '/config.js'; -let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION }; +let state = { items: [], currentId: null, background: AUTO_BACKGROUND, animation: AUTO_ANIMATION }; // Last playhead report, kept with its arrival time so the countdown can be // extrapolated smoothly between the 1 Hz updates. let progress = { id: null, currentTime: 0, at: 0 }; @@ -209,15 +209,18 @@ function buildSwitcher(box, options, type, currentKey) { }; } +// Auto leads in both rows: it is the normal party setting, the named ones are +// for holding still on one while you look at it. const renderBackground = buildSwitcher( backgroundBox, - Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]), + [ + [AUTO_BACKGROUND, 'Auto'], + ...Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]), + ], 'setBackground', () => state.background, ); -// Auto leads: it is the normal party setting, the named ones are for testing -// a single animation without waiting for the cycle to land on it. const renderAnimation = buildSwitcher( animationBox, [ diff --git a/server/index.js b/server/index.js index 2cd1df9..a1b8ab0 100644 --- a/server/index.js +++ b/server/index.js @@ -8,7 +8,7 @@ import express from 'express'; import { WebSocketServer } from 'ws'; import * as config from '../config.js'; -import { PORT, BACKGROUNDS } from '../config.js'; +import { PORT } from '../config.js'; import { parseVideoId, fetchTitle } from './youtube.js'; import * as store from './state.js'; @@ -101,7 +101,7 @@ const handlers = { }, setBackground(_ws, msg) { - if (!(msg.key in BACKGROUNDS)) return; + if (!store.isBackground(msg.key)) return; store.getState().background = msg.key; commit(); }, diff --git a/server/state.js b/server/state.js index e5e9a0b..ff5040f 100644 --- a/server/state.js +++ b/server/state.js @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'; import { DEFAULT_BACKGROUND, BACKGROUNDS, + AUTO_BACKGROUND, DEFAULT_ANIMATION, ANIMATIONS, AUTO_ANIMATION, @@ -33,6 +34,11 @@ export function isAnimation(key) { return key === AUTO_ANIMATION || key in ANIMATIONS; } +/** The same, for the background rotation. */ +export function isBackground(key) { + return key === AUTO_BACKGROUND || key in BACKGROUNDS; +} + export function load() { try { const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8')); @@ -46,7 +52,7 @@ export function load() { duration: Number.isFinite(item.duration) ? item.duration : null, })); } - if (saved.background in BACKGROUNDS) state.background = saved.background; + if (isBackground(saved.background)) state.background = saved.background; if (isAnimation(saved.animation)) state.animation = saved.animation; // Resume at the item that was playing, otherwise at the top of the list. state.currentId = state.items.some((i) => i.id === saved.currentId)