diff --git a/README.md b/README.md index 81bc3a9..af7cf5f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ A YouTube playlist player for parties. Two browser windows run side by side: [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. + reorder, delete, see how much playtime is left, and pick the background and + animation. ## Run @@ -32,9 +33,9 @@ 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. Anything - the browser needs must also be mirrored in the `/config.js` route in - `server/index.js`. + 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`. ## Animations @@ -45,6 +46,11 @@ The video frame's movement changes with every track. Two animations ship today: 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 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. +**Auto** (the default) hands selection back to the shuffle bag. Switching takes +effect immediately; while pinned, track changes leave the animation running. + **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. @@ -64,8 +70,9 @@ 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. +the same element immediately. Register it under a new name in the `ANIMATIONS` +map in `motion.js` **and** in the one in `config.js` (which supplies the label +for the queue page's button), 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 diff --git a/config.js b/config.js index 49a7b6c..6bef4e1 100644 --- a/config.js +++ b/config.js @@ -23,6 +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 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' }, +}; + +// Cycle a fresh random animation on every track change. Anything else pins the +// player to that one animation, which is what the queue page's buttons are for. +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' }, diff --git a/public/player/motion.js b/public/player/motion.js index a4df561..d0456d0 100644 --- a/public/player/motion.js +++ b/public/player/motion.js @@ -11,6 +11,7 @@ import { BOUNCE_SPEED, CORNER_INTERVAL_SECONDS, CORNER_MARGIN, + AUTO_ANIMATION, } from '/config.js'; const ASPECT = 9 / 16; @@ -261,33 +262,54 @@ export function startCorners(context) { // --- registry ----------------------------------------------------------- -export const ANIMATIONS = [ - { name: 'bounce', start: startBounce }, - { name: 'corners', start: startCorners }, -]; +// Keyed by the names in config.js, which is what travels over the socket. +export const ANIMATIONS = { + bounce: startBounce, + corners: startCorners, +}; + +const NAMES = Object.keys(ANIMATIONS); /** - * Run one animation at a time, swapping to a freshly picked one on next(). - * Returns the handle the player drives from its track changes. + * Run one animation at a time. In auto mode next() swaps to a freshly picked + * one on every track change; pinned to a name (from the queue page, for + * testing) it stays put until the mode changes again. */ -export function startAnimationCycle(context) { - const picker = createPicker(ANIMATIONS.length); +export function startAnimationCycle(context, mode = AUTO_ANIMATION) { + const picker = createPicker(NAMES.length); let stop = null; let current = null; - function next() { + function run(name) { stop?.(); - current = ANIMATIONS[picker.next()]; - stop = current.start(context); - return current.name; + current = name; + stop = ANIMATIONS[name](context); + return current; } - next(); + function next() { + // Pinned: the track changed, but the operator asked for this one. + if (mode !== AUTO_ANIMATION) return current; + return run(NAMES[picker.next()]); + } + + function setMode(value) { + mode = ANIMATIONS[value] ? value : AUTO_ANIMATION; + // Always restarts, so switching back to auto takes effect immediately + // rather than waiting out the current track. + return mode === AUTO_ANIMATION ? run(NAMES[picker.next()]) : run(mode); + } + + setMode(mode); return { next, + setMode, + get mode() { + return mode; + }, get name() { - return current?.name ?? null; + return current; }, }; } diff --git a/public/player/player.js b/public/player/player.js index 31b6fc9..fbb26c8 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -1,12 +1,12 @@ import { connect } from '/shared/socket.js'; -import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.js'; +import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS, AUTO_ANIMATION } from '/config.js'; import { createPlayer, loadApi, STATE } from './yt.js'; import { startAnimationCycle } from './motion.js'; import { requestCapture } from './capture.js'; const TICK_MS = 250; -let state = { items: [], currentId: null, background: 'pink' }; +let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION }; let decks = []; let active = 0; let transition = null; // { until, duration, from, to } while crossfading @@ -74,7 +74,7 @@ async function start() { animationContext.capture = capture; }); - animations = startAnimationCycle(animationContext); + animations = startAnimationCycle(animationContext, state.animation); decks = await Promise.all([ makeDeck('deck-a', 0), @@ -325,8 +325,10 @@ initProbe(); function onMessage(msg) { if (msg.type !== 'state') return; + const previousAnimation = state.animation; state = msg.state; applyBackground(state.background); + if (state.animation !== previousAnimation) animations?.setMode(state.animation); updateGate(); pumpProbe(); } diff --git a/public/queue/index.html b/public/queue/index.html index 09f8991..e71b9e4 100644 --- a/public/queue/index.html +++ b/public/queue/index.html @@ -39,8 +39,14 @@

Nothing queued yet.

diff --git a/public/queue/queue.css b/public/queue/queue.css index 17ea70a..4a49d53 100644 --- a/public/queue/queue.css +++ b/public/queue/queue.css @@ -241,13 +241,20 @@ footer { border-top: 1px solid #331630; } -#backgrounds { +footer .switcher + .switcher { + margin-top: 18px; +} + +#backgrounds, +#animations { display: flex; + flex-wrap: wrap; gap: 8px; margin-top: 8px; } -#backgrounds button { +#backgrounds button, +#animations button { font: inherit; padding: 8px 18px; border-radius: 999px; @@ -257,7 +264,8 @@ footer { cursor: pointer; } -#backgrounds button.active { +#backgrounds button.active, +#animations button.active { background: var(--accent); border-color: var(--accent); color: var(--ink); diff --git a/public/queue/queue.js b/public/queue/queue.js index 547232b..f1673d2 100644 --- a/public/queue/queue.js +++ b/public/queue/queue.js @@ -1,7 +1,7 @@ import { connect, formatDuration } from '/shared/socket.js'; -import { BACKGROUNDS } from '/config.js'; +import { BACKGROUNDS, ANIMATIONS, AUTO_ANIMATION } from '/config.js'; -let state = { items: [], currentId: null, background: 'pink' }; +let state = { items: [], currentId: null, background: 'pink', 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 }; @@ -11,6 +11,7 @@ const empty = document.getElementById('empty'); const errorBox = document.getElementById('error'); const statusBox = document.getElementById('status'); const backgroundBox = document.getElementById('backgrounds'); +const animationBox = document.getElementById('animations'); const socket = connect({ onMessage, onStatus }); @@ -183,25 +184,50 @@ function clearDropMarkers() { } } -// --- background switcher ------------------------------------------------ +// --- background / animation switchers ----------------------------------- -const backgroundButtons = new Map(); +/** + * A row of buttons that sets one field of the shared state. Returns a render + * function that lights up whichever key the server currently reports. + */ +function buildSwitcher(box, options, type, currentKey) { + const buttons = new Map(); -for (const [key, { label }] of Object.entries(BACKGROUNDS)) { - const button = document.createElement('button'); - button.type = 'button'; - button.textContent = label; - button.addEventListener('click', () => socket.send({ type: 'setBackground', key })); - backgroundBox.appendChild(button); - backgroundButtons.set(key, button); -} - -function renderBackground() { - for (const [key, button] of backgroundButtons) { - button.classList.toggle('active', key === state.background); + for (const [key, label] of options) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.addEventListener('click', () => socket.send({ type, key })); + box.appendChild(button); + buttons.set(key, button); } + + return function render() { + for (const [key, button] of buttons) { + button.classList.toggle('active', key === currentKey()); + } + }; } +const renderBackground = buildSwitcher( + backgroundBox, + 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, + [ + [AUTO_ANIMATION, 'Auto'], + ...Object.entries(ANIMATIONS).map(([key, { label }]) => [key, label]), + ], + 'setAnimation', + () => state.animation, +); + // --- socket ------------------------------------------------------------- function onMessage(msg) { @@ -209,6 +235,7 @@ function onMessage(msg) { state = msg.state; render(); renderBackground(); + renderAnimation(); } else if (msg.type === 'progress') { progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() }; renderTotals(); diff --git a/server/index.js b/server/index.js index b2e067a..c718c2e 100644 --- a/server/index.js +++ b/server/index.js @@ -16,6 +16,8 @@ import { CORNER_INTERVAL_SECONDS, CORNER_MARGIN, BACKGROUNDS, + ANIMATIONS, + AUTO_ANIMATION, } from '../config.js'; import { parseVideoId, fetchTitle } from './youtube.js'; import * as store from './state.js'; @@ -43,7 +45,9 @@ 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 BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n`, + `export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` + + `export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` + + `export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\n`, ); }); @@ -112,6 +116,12 @@ const handlers = { commit(); }, + setAnimation(_ws, msg) { + if (!store.isAnimation(msg.key)) return; + store.getState().animation = msg.key; + commit(); + }, + // Relayed straight through: this arrives once a second and must never // touch the disk. progress(_ws, msg) { diff --git a/server/state.js b/server/state.js index 618f9c8..e5e9a0b 100644 --- a/server/state.js +++ b/server/state.js @@ -6,7 +6,13 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { randomUUID } from 'node:crypto'; -import { DEFAULT_BACKGROUND, BACKGROUNDS } from '../config.js'; +import { + DEFAULT_BACKGROUND, + BACKGROUNDS, + DEFAULT_ANIMATION, + ANIMATIONS, + AUTO_ANIMATION, +} from '../config.js'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const STORE_PATH = join(ROOT, 'queue.json'); @@ -15,12 +21,18 @@ const state = { items: [], currentId: null, background: DEFAULT_BACKGROUND, + animation: DEFAULT_ANIMATION, }; export function getState() { return state; } +/** Is this a name the player can be pinned to (or the auto cycle)? */ +export function isAnimation(key) { + return key === AUTO_ANIMATION || key in ANIMATIONS; +} + export function load() { try { const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8')); @@ -35,6 +47,7 @@ export function load() { })); } if (saved.background in BACKGROUNDS) 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) ? saved.currentId @@ -52,7 +65,12 @@ export function load() { */ export function save() { const payload = JSON.stringify( - { items: state.items, currentId: state.currentId, background: state.background }, + { + items: state.items, + currentId: state.currentId, + background: state.background, + animation: state.animation, + }, null, 2, );