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