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
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
// 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`);
|
||||
});
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
// The single source of truth for the queue, plus JSON persistence so an
|
||||
// accidental restart mid-party does not wipe the playlist.
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { DEFAULT_BACKGROUND, BACKGROUNDS } from '../config.js';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const STORE_PATH = join(ROOT, 'queue.json');
|
||||
|
||||
const state = {
|
||||
items: [],
|
||||
currentId: null,
|
||||
background: DEFAULT_BACKGROUND,
|
||||
};
|
||||
|
||||
export function getState() {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function load() {
|
||||
try {
|
||||
const saved = JSON.parse(readFileSync(STORE_PATH, 'utf8'));
|
||||
if (Array.isArray(saved.items)) {
|
||||
state.items = saved.items
|
||||
.filter((item) => item && item.videoId)
|
||||
.map((item) => ({
|
||||
id: item.id || randomUUID(),
|
||||
videoId: item.videoId,
|
||||
title: item.title || item.videoId,
|
||||
duration: Number.isFinite(item.duration) ? item.duration : null,
|
||||
}));
|
||||
}
|
||||
if (saved.background in BACKGROUNDS) state.background = saved.background;
|
||||
// Resume at the item that was playing, otherwise at the top of the list.
|
||||
state.currentId = state.items.some((i) => i.id === saved.currentId)
|
||||
? saved.currentId
|
||||
: state.items[0]?.id ?? null;
|
||||
console.log(`[state] restored ${state.items.length} item(s) from queue.json`);
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') console.warn(`[state] could not read queue.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write through a temp file so a crash mid-write cannot leave a truncated
|
||||
* queue.json behind. Called only on structural changes — never on progress
|
||||
* ticks, which arrive once a second all night.
|
||||
*/
|
||||
export function save() {
|
||||
const payload = JSON.stringify(
|
||||
{ items: state.items, currentId: state.currentId, background: state.background },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
try {
|
||||
const tmp = `${STORE_PATH}.tmp`;
|
||||
writeFileSync(tmp, payload);
|
||||
renameSync(tmp, STORE_PATH);
|
||||
} catch (err) {
|
||||
console.warn(`[state] could not write queue.json: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function indexOf(id) {
|
||||
return state.items.findIndex((item) => item.id === id);
|
||||
}
|
||||
|
||||
export function addItem({ videoId, title }) {
|
||||
const item = { id: randomUUID(), videoId, title, duration: null };
|
||||
state.items.push(item);
|
||||
if (!state.currentId) state.currentId = item.id;
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item. When the currently playing one goes, the current pointer
|
||||
* moves to whatever slid into its place (or the new last item), so the player
|
||||
* has somewhere to fade to.
|
||||
*/
|
||||
export function removeItem(id) {
|
||||
const index = indexOf(id);
|
||||
if (index === -1) return false;
|
||||
|
||||
const wasCurrent = state.currentId === id;
|
||||
state.items.splice(index, 1);
|
||||
|
||||
if (wasCurrent) {
|
||||
const next = state.items[index] || state.items[state.items.length - 1] || null;
|
||||
state.currentId = next ? next.id : null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Reorder to match the given id order, ignoring ids that are no longer present. */
|
||||
export function reorder(ids) {
|
||||
const byId = new Map(state.items.map((item) => [item.id, item]));
|
||||
const reordered = ids.map((id) => byId.get(id)).filter(Boolean);
|
||||
// Anything the client did not know about keeps its place at the end.
|
||||
for (const item of state.items) {
|
||||
if (!reordered.includes(item)) reordered.push(item);
|
||||
}
|
||||
state.items = reordered;
|
||||
}
|
||||
|
||||
export function setDuration(id, seconds) {
|
||||
const item = state.items[indexOf(id)];
|
||||
if (!item || !Number.isFinite(seconds) || seconds <= 0) return false;
|
||||
if (item.duration === seconds) return false;
|
||||
item.duration = seconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The item after `id`, wrapping around so the party never stops. */
|
||||
export function nextItem(id) {
|
||||
if (state.items.length === 0) return null;
|
||||
const index = indexOf(id);
|
||||
if (index === -1) return state.items[0];
|
||||
return state.items[(index + 1) % state.items.length];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Keyless YouTube metadata: id parsing plus title lookup via the public oEmbed
|
||||
// endpoint. Durations are not available here — they come from the probe player
|
||||
// in the player frontend and arrive over the socket later.
|
||||
|
||||
const ID_PATTERN = /^[A-Za-z0-9_-]{11}$/;
|
||||
|
||||
/**
|
||||
* Pull the 11-character video id out of anything a user might paste.
|
||||
* Returns null when the input is not a recognisable YouTube video.
|
||||
*/
|
||||
export function parseVideoId(input) {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
// A bare id pasted on its own.
|
||||
if (ID_PATTERN.test(raw)) return raw;
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw.includes('://') ? raw : `https://${raw}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = url.hostname.replace(/^www\./, '');
|
||||
const segments = url.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (host === 'youtu.be') {
|
||||
return ID_PATTERN.test(segments[0]) ? segments[0] : null;
|
||||
}
|
||||
|
||||
if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com' || host === 'youtube-nocookie.com') {
|
||||
const v = url.searchParams.get('v');
|
||||
if (v && ID_PATTERN.test(v)) return v;
|
||||
|
||||
// /shorts/ID, /embed/ID, /live/ID, /v/ID
|
||||
if (['shorts', 'embed', 'live', 'v'].includes(segments[0])) {
|
||||
return ID_PATTERN.test(segments[1]) ? segments[1] : null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the video title. Falls back to the id so a failed lookup still leaves
|
||||
* a usable queue entry rather than blocking the add.
|
||||
*/
|
||||
export async function fetchTitle(videoId) {
|
||||
const endpoint = `https://www.youtube.com/oembed?url=${encodeURIComponent(
|
||||
`https://www.youtube.com/watch?v=${videoId}`,
|
||||
)}&format=json`;
|
||||
|
||||
try {
|
||||
const res = await fetch(endpoint, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) throw new Error(`oembed ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.title || videoId;
|
||||
} catch (err) {
|
||||
console.warn(`[youtube] title lookup failed for ${videoId}: ${err.message}`);
|
||||
return videoId;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user