Waiting for the shuffle bag to land on the animation you are working on makes testing one of them tedious. The queue page now carries an Animation row next to Background: Auto keeps the per-track cycle, any other button pins the player to that animation until the mode changes again. The animation names live in config.js alongside their labels, so the server can validate them and the queue page can label its buttons; motion.js keys its registry by the same names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
142 lines
4.3 KiB
JavaScript
142 lines
4.3 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,
|
|
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,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
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];
|
|
}
|