Files
party-player/public/queue/queue.js
T
simonandClaude Opus 5 db672b8b32 Rotate the background with every track
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
2026-08-10 20:54:10 +02:00

254 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { connect, formatDuration } from '/shared/socket.js';
import { BACKGROUNDS, AUTO_BACKGROUND, ANIMATIONS, AUTO_ANIMATION } from '/config.js';
let state = { items: [], currentId: null, background: AUTO_BACKGROUND, animation: AUTO_ANIMATION };
// Last playhead report, kept with its arrival time so the countdown can be
// extrapolated smoothly between the 1 Hz updates.
let progress = { id: null, currentTime: 0, at: 0 };
const list = document.getElementById('list');
const empty = document.getElementById('empty');
const errorBox = document.getElementById('error');
const statusBox = document.getElementById('status');
const backgroundBox = document.getElementById('backgrounds');
const animationBox = document.getElementById('animations');
const socket = connect({ onMessage, onStatus });
// --- adding -------------------------------------------------------------
document.getElementById('add-form').addEventListener('submit', (event) => {
event.preventDefault();
const input = document.getElementById('url');
const url = input.value.trim();
if (!url) return;
showError(null);
socket.send({ type: 'add', url });
input.value = '';
input.focus();
});
function showError(message) {
errorBox.textContent = message || '';
errorBox.hidden = !message;
}
// --- rendering ----------------------------------------------------------
function render() {
list.replaceChildren(...state.items.map(renderItem));
empty.hidden = state.items.length > 0;
renderTotals();
}
function renderItem(item, index) {
const li = document.createElement('li');
li.className = 'item';
li.dataset.id = item.id;
li.draggable = true;
if (item.id === state.currentId) li.classList.add('current');
const handle = document.createElement('span');
handle.className = 'handle';
handle.textContent = '⠿';
handle.setAttribute('aria-hidden', 'true');
const position = document.createElement('span');
position.className = 'index';
position.textContent = item.id === state.currentId ? '▶' : String(index + 1);
const title = document.createElement('span');
title.className = 'title';
title.textContent = item.title;
title.title = item.title;
const duration = document.createElement('span');
duration.className = 'duration';
duration.textContent = item.duration === null ? '–:––' : formatDuration(item.duration);
const remove = document.createElement('button');
remove.className = 'remove';
remove.type = 'button';
remove.textContent = '×';
remove.title = 'Remove';
remove.addEventListener('click', (event) => {
event.stopPropagation();
socket.send({ type: 'remove', id: item.id });
});
li.append(handle, position, title, duration, remove);
li.addEventListener('click', () => socket.send({ type: 'jump', id: item.id }));
attachDrag(li);
return li;
}
function renderTotals() {
let total = 0;
let remaining = 0;
let unknown = false;
let passedCurrent = false;
const currentIndex = state.items.findIndex((item) => item.id === state.currentId);
state.items.forEach((item, index) => {
if (item.duration === null) {
unknown = true;
return;
}
total += item.duration;
if (currentIndex === -1) return;
if (index === currentIndex) {
remaining += Math.max(0, item.duration - playheadFor(item));
passedCurrent = true;
} else if (index > currentIndex) {
remaining += item.duration;
}
});
if (currentIndex === -1) remaining = total;
else if (!passedCurrent) unknown = true;
const prefix = unknown ? '≥ ' : '';
document.getElementById('total').textContent = prefix + formatDuration(total);
document.getElementById('remaining').textContent = prefix + formatDuration(remaining);
}
/** Playhead of the current item, extrapolated since the last report. */
function playheadFor(item) {
if (progress.id !== item.id) return 0;
const elapsed = (Date.now() - progress.at) / 1000;
return Math.min(progress.currentTime + elapsed, item.duration ?? Infinity);
}
// Keeps the countdown ticking between server messages.
setInterval(renderTotals, 1000);
// --- drag to reorder ----------------------------------------------------
let draggingId = null;
function attachDrag(li) {
li.addEventListener('dragstart', (event) => {
draggingId = li.dataset.id;
li.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
// Firefox refuses to start a drag without payload.
event.dataTransfer.setData('text/plain', li.dataset.id);
});
li.addEventListener('dragend', () => {
draggingId = null;
li.classList.remove('dragging');
clearDropMarkers();
});
li.addEventListener('dragover', (event) => {
if (!draggingId || li.dataset.id === draggingId) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
const box = li.getBoundingClientRect();
const after = event.clientY > box.top + box.height / 2;
clearDropMarkers();
li.classList.add(after ? 'drop-after' : 'drop-before');
});
li.addEventListener('dragleave', () => {
li.classList.remove('drop-before', 'drop-after');
});
li.addEventListener('drop', (event) => {
if (!draggingId || li.dataset.id === draggingId) return;
event.preventDefault();
const box = li.getBoundingClientRect();
const after = event.clientY > box.top + box.height / 2;
const ids = state.items.map((item) => item.id).filter((id) => id !== draggingId);
const target = ids.indexOf(li.dataset.id);
ids.splice(after ? target + 1 : target, 0, draggingId);
clearDropMarkers();
// Apply locally so the list does not flicker back before the echo lands.
const byId = new Map(state.items.map((item) => [item.id, item]));
state.items = ids.map((id) => byId.get(id));
render();
socket.send({ type: 'reorder', ids });
});
}
function clearDropMarkers() {
for (const el of list.querySelectorAll('.drop-before, .drop-after')) {
el.classList.remove('drop-before', 'drop-after');
}
}
// --- background / animation switchers -----------------------------------
/**
* A row of buttons that sets one field of the shared state. Returns a render
* function that lights up whichever key the server currently reports.
*/
function buildSwitcher(box, options, type, currentKey) {
const buttons = new Map();
for (const [key, label] of options) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = label;
button.addEventListener('click', () => socket.send({ type, key }));
box.appendChild(button);
buttons.set(key, button);
}
return function render() {
for (const [key, button] of buttons) {
button.classList.toggle('active', key === currentKey());
}
};
}
// Auto leads in both rows: it is the normal party setting, the named ones are
// for holding still on one while you look at it.
const renderBackground = buildSwitcher(
backgroundBox,
[
[AUTO_BACKGROUND, 'Auto'],
...Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]),
],
'setBackground',
() => state.background,
);
const renderAnimation = buildSwitcher(
animationBox,
[
[AUTO_ANIMATION, 'Auto'],
...Object.entries(ANIMATIONS).map(([key, { label }]) => [key, label]),
],
'setAnimation',
() => state.animation,
);
// --- socket -------------------------------------------------------------
function onMessage(msg) {
if (msg.type === 'state') {
state = msg.state;
render();
renderBackground();
renderAnimation();
} else if (msg.type === 'progress') {
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
renderTotals();
} else if (msg.type === 'error') {
showError(msg.message);
}
}
function onStatus(status) {
statusBox.textContent = status;
statusBox.className = status;
}