Files
party-player/server/index.js
T
simonandClaude Opus 5 6d306d1b98 Add a comet trail animation
The frame glides from waypoint to waypoint and drops a screenshot of
itself every frame-width or so. The stills stay where they were taken
and fade over a few seconds, so the live video reads as the head of a
comet whose tail is its own recent past.

The steering caps how fast the heading may change: an instant turn would
lay the stills out in straight dotted lines, and it is the curve that
makes a tail read as one. Drops are spaced by distance travelled rather
than by a timer, so they stay even however the frame is turning.

Since the capture is a crop of the tab, a still lying under the frame
would be photographed into the next one. A drop waits until the frame is
clear of the tail, keeping the invariant corners already relies on.

Also picks up the FRAME_SCALE, BOUNCE_SPEED and CORNER_INTERVAL_SECONDS
tuning sitting in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:58:08 +02:00

174 lines
5.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,
DRAG_FRAME_SCALE,
DRAG_MIN_SCALE,
DRAG_MAX_SCALE,
TRAIL_SPEED,
TRAIL_TURN_RATE,
TRAIL_SPACING,
TRAIL_LENGTH,
TRAIL_FADE_SECONDS,
BACKGROUNDS,
ANIMATIONS,
AUTO_ANIMATION,
} 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 DRAG_FRAME_SCALE = ${DRAG_FRAME_SCALE};\n` +
`export const DRAG_MIN_SCALE = ${DRAG_MIN_SCALE};\n` +
`export const DRAG_MAX_SCALE = ${DRAG_MAX_SCALE};\n` +
`export const TRAIL_SPEED = ${TRAIL_SPEED};\n` +
`export const TRAIL_TURN_RATE = ${TRAIL_TURN_RATE};\n` +
`export const TRAIL_SPACING = ${TRAIL_SPACING};\n` +
`export const TRAIL_LENGTH = ${TRAIL_LENGTH};\n` +
`export const TRAIL_FADE_SECONDS = ${TRAIL_FADE_SECONDS};\n` +
`export const BACKGROUNDS = ${JSON.stringify(BACKGROUNDS)};\n` +
`export const ANIMATIONS = ${JSON.stringify(ANIMATIONS)};\n` +
`export const AUTO_ANIMATION = ${JSON.stringify(AUTO_ANIMATION)};\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();
},
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) {
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`);
});