The poster behind the frame was one of two, picked by hand and then left there all night. It now turns over on every track change the way the animations do, and never as the same picture twice: a shuffle bag over whatever images sit in assets/, each shown at a random zoom between 1.15 and 1.9 and cropped around a randomly chosen point of the poster. The list is read from the directory rather than written out in config.js. The old one named two files that are no longer there, which is the failure mode a hand-kept list has; now a poster joins the rotation, with its own button on the queue page, by being dropped in the folder. Zoom never goes below 1.15. These are portrait prints and the screen is wide, so cover fits the poster's full width and crops vertically — at 1 the paper margin shows as a grey stripe down both sides. 1.15 cuts at least 13% off the width of every crop a 1920x1080 screen can produce. The crop is drawn into a screen-sized canvas rather than handed to CSS as an object-fit rule on a full-size <img>. The scans are 3508x4950, which is a 70MB texture per layer and two of them composited for the length of the fade; instead each pick is fetched, resized off the main thread to roughly screen resolution, drawn once, and closed. A swap costs about 400ms of async work, all of it before the fade begins, and no full-size bitmap is held anywhere. Nothing about the posters' resolution reaches the compositor, so assets/ can keep the 300 dpi originals. Backgrounds pin and unpin like animations: DEFAULT_BACKGROUND is now the auto cycle, the queue page's row leads with Auto, and isBackground() guards the socket handler and the restored queue.json against a key that no longer names a file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
148 lines
4.4 KiB
JavaScript
148 lines
4.4 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,
|
|
};
|
|
|
|
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];
|
|
}
|