The browser gets its constants from a /config.js route that re-declared each export by hand, so a constant added to config.js and imported by the frontend linked against nothing. WASH_FRAME_SCALE, added with the colour wash, did exactly that: motion.js failed to link, player.js never ran, and the start gate sat on its hardcoded "Loading queue..." forever. Serialise every export except the server-only PORT, so the mirror cannot drift from the source again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
148 lines
4.1 KiB
JavaScript
148 lines
4.1 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 * as config from '../config.js';
|
|
import { PORT, 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.
|
|
// Generated from the module's exports rather than listed by hand: a constant
|
|
// added to config.js and imported by the frontend used to break the whole
|
|
// module graph — and with it the player — until this list was updated too.
|
|
// PORT is server-only; everything else travels.
|
|
const CLIENT_CONFIG = Object.entries(config).filter(([name]) => name !== 'PORT');
|
|
|
|
app.get('/config.js', (_req, res) => {
|
|
res
|
|
.type('application/javascript')
|
|
.send(
|
|
CLIENT_CONFIG.map(([name, value]) => `export const ${name} = ${JSON.stringify(value)};\n`).join(''),
|
|
);
|
|
});
|
|
|
|
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`);
|
|
});
|