Files
party-player/server/state.js
T
simonandClaude Opus 5 ad999e1c3e 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
2026-08-09 21:22:13 +02:00

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];
}