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
124 lines
3.9 KiB
JavaScript
124 lines
3.9 KiB
JavaScript
// 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];
|
|
}
|