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
251 lines
7.6 KiB
JavaScript
251 lines
7.6 KiB
JavaScript
import { connect, formatDuration } from '/shared/socket.js';
|
||
import { BACKGROUNDS, ANIMATIONS, AUTO_ANIMATION } from '/config.js';
|
||
|
||
let state = { items: [], currentId: null, background: 'pink', 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());
|
||
}
|
||
};
|
||
}
|
||
|
||
const renderBackground = buildSwitcher(
|
||
backgroundBox,
|
||
Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]),
|
||
'setBackground',
|
||
() => state.background,
|
||
);
|
||
|
||
// Auto leads: it is the normal party setting, the named ones are for testing
|
||
// a single animation without waiting for the cycle to land on it.
|
||
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;
|
||
}
|