Files
2026-08-12 18:02:01 +02:00

151 lines
4.6 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,
AUTO_BACKGROUND,
DEFAULT_ANIMATION,
ANIMATIONS,
AUTO_ANIMATION,
} 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,
animation: DEFAULT_ANIMATION,
// Deliberately absent from save(): a restart should bring the music back,
// not leave the party silently paused because of a click hours ago.
paused: false,
};
export function getState() {
return state;
}
/** Is this a name the player can be pinned to (or the auto cycle)? */
export function isAnimation(key) {
return key === AUTO_ANIMATION || key in ANIMATIONS;
}
/** The same, for the background rotation. */
export function isBackground(key) {
return key === AUTO_BACKGROUND || key in BACKGROUNDS;
}
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 (isBackground(saved.background)) state.background = saved.background;
if (isAnimation(saved.animation)) state.animation = saved.animation;
// 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,
animation: state.animation,
},
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];
}