// 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_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_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`); });