151 lines
4.6 KiB
JavaScript
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];
|
|
}
|