Files
party-player/server/index.js
T
simonandClaude Opus 5 ad999e1c3e Add party player: YouTube queue with crossfading playback
Two frontends sharing one WebSocket-backed state on the server:

- Player: fullscreen poster background with a small frame (30% of the
  screen) that drifts diagonally and reflects off the edges. Two stacked
  YouTube players hand over with a 5s equal-power crossfade so there is
  never silence between tracks.
- Queue: add by URL, drag to reorder, delete, click to jump. Shows title
  and length per item, highlights what is playing, and tracks remaining
  vs. total playtime.

No API key is required — titles come from YouTube's oEmbed endpoint and
durations from a hidden muted probe player that cues each new video just
to read its length.

The fade is driven by both requestAnimationFrame and the interval tick:
a hidden or throttled tab stops serving frames entirely, which would
otherwise strand a handover half-faded with two tracks audible.

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

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