The frame had exactly one behaviour, started once and run forever. Make animations a start(context) => stop() lifecycle held in a registry, and draw a new one on every completed crossfade. Selection uses a shuffle bag: each animation comes up exactly once per pass, so they stay evenly used, and a swap at the pass boundary keeps the same one from running twice in a row. Written for a registry larger than today's two entries. The new corners animation parks the frame in a random corner and drops a screenshot of it into the next of the other three corners every 5s, going clockwise. The frame's own corner is never a slot, so a still cannot cover it by construction rather than by clamping. This holds while FRAME_SCALE stays under 0.5, now noted where that knob lives. Screenshots need getDisplayMedia: the decks are cross-origin YouTube iframes, so canvas taints and there is no other way to read the playing pixels. The prompt has to be requested before start() yields, or the click's transient activation is gone. Declining, or serving over plain HTTP where mediaDevices is undefined, leaves the frame parked without stills instead of failing. The capture sink is a 1x1 element in the document, like #probe: detached video elements are never composited and get throttled hard enough to make every screenshot seconds stale. startBounce also gains the stop() it always needed — it was leaking its rAF loop and resize listener, and a queued frame would fight whichever animation came next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
148 lines
4.0 KiB
JavaScript
148 lines
4.0 KiB
JavaScript
// Express serves the two frontends; a single WebSocket keeps them in sync.
|
|
|
|
import { createServer } from 'node:http';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import express from 'express';
|
|
import { WebSocketServer } from 'ws';
|
|
|
|
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';
|
|
|
|
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
store.load();
|
|
|
|
const app = express();
|
|
|
|
// Declared before the static middleware, which would otherwise redirect
|
|
// /queue to /queue/ on its way to the directory index.
|
|
app.get('/', (_req, res) => res.sendFile(join(ROOT, 'public/player/index.html')));
|
|
app.get('/queue', (_req, res) => res.sendFile(join(ROOT, 'public/queue/index.html')));
|
|
|
|
app.use('/assets', express.static(join(ROOT, 'assets'), { maxAge: '1h' }));
|
|
app.use(express.static(join(ROOT, 'public')));
|
|
|
|
// Client-side mirror of the shared config, so the constants live in one place.
|
|
app.get('/config.js', (_req, res) => {
|
|
res.type('application/javascript').send(
|
|
`export const OVERLAP_SECONDS = ${OVERLAP_SECONDS};\n` +
|
|
`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`,
|
|
);
|
|
});
|
|
|
|
const server = createServer(app);
|
|
const wss = new WebSocketServer({ server });
|
|
|
|
function broadcast(message) {
|
|
const payload = JSON.stringify(message);
|
|
for (const client of wss.clients) {
|
|
if (client.readyState === client.OPEN) client.send(payload);
|
|
}
|
|
}
|
|
|
|
function broadcastState() {
|
|
broadcast({ type: 'state', state: store.getState() });
|
|
}
|
|
|
|
/** Persist and push in one go — every structural change ends with this. */
|
|
function commit() {
|
|
store.save();
|
|
broadcastState();
|
|
}
|
|
|
|
const handlers = {
|
|
async add(_ws, msg) {
|
|
const videoId = parseVideoId(msg.url);
|
|
if (!videoId) throw new Error('Not a YouTube video URL');
|
|
const title = await fetchTitle(videoId);
|
|
store.addItem({ videoId, title });
|
|
commit();
|
|
},
|
|
|
|
remove(_ws, msg) {
|
|
if (store.removeItem(msg.id)) commit();
|
|
},
|
|
|
|
reorder(_ws, msg) {
|
|
if (!Array.isArray(msg.ids)) return;
|
|
store.reorder(msg.ids);
|
|
commit();
|
|
},
|
|
|
|
jump(_ws, msg) {
|
|
if (store.indexOf(msg.id) === -1) return;
|
|
store.getState().currentId = msg.id;
|
|
commit();
|
|
},
|
|
|
|
duration(_ws, msg) {
|
|
if (store.setDuration(msg.id, msg.seconds)) commit();
|
|
},
|
|
|
|
// The player finished its crossfade and wants the pointer moved along.
|
|
trackEnded(_ws, msg) {
|
|
const state = store.getState();
|
|
// Ignore stale reports from a track we already moved past.
|
|
if (msg.id !== state.currentId) return;
|
|
const next = store.nextItem(msg.id);
|
|
state.currentId = next ? next.id : null;
|
|
commit();
|
|
},
|
|
|
|
setBackground(_ws, msg) {
|
|
if (!(msg.key in BACKGROUNDS)) return;
|
|
store.getState().background = msg.key;
|
|
commit();
|
|
},
|
|
|
|
// Relayed straight through: this arrives once a second and must never
|
|
// touch the disk.
|
|
progress(_ws, msg) {
|
|
broadcast({ type: 'progress', id: msg.id, currentTime: msg.currentTime });
|
|
},
|
|
};
|
|
|
|
wss.on('connection', (ws) => {
|
|
ws.send(JSON.stringify({ type: 'state', state: store.getState() }));
|
|
|
|
ws.on('message', async (raw) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(raw);
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const handler = handlers[msg.type];
|
|
if (!handler) return;
|
|
|
|
try {
|
|
await handler(ws, msg);
|
|
} catch (err) {
|
|
ws.send(JSON.stringify({ type: 'error', message: err.message, forType: msg.type }));
|
|
}
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`\n Player : http://localhost:${PORT}/`);
|
|
console.log(` Queue : http://localhost:${PORT}/queue\n`);
|
|
});
|