Files
party-player/server/index.js
T
simonandClaude Opus 5 2066966694 Give the drag animation its own base frame size
The shared FRAME_SCALE has to stay under 0.5 so the corners animation can
fit two frames side by side, but nothing else is on screen while the drag
animation runs, so it can afford a roomier frame.

frameSize() now takes the scale as an argument; drag passes
DRAG_FRAME_SCALE and the min/max resize multipliers apply on top of it.

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

164 lines
4.6 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,
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 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`);
});