Files
party-player/public/queue/queue.js
T
SiandClaude Opus 5 554b946235 Slow every animation down for a low-res screen
Every speed in config.js is in pixels per second, so the same numbers cross a
small screen in less time than a big one: on 1280x800 the frame covers the
width in two thirds of the time it takes on 1920x1080, and the player reads as
hurried.

One factor now sits behind every animation clock — tickSeconds() for the
frame loops, `speed` for the corners timer, and a --motion-stretch custom
property for the two CSS fades — so the Speed switch on the queue page slows
the drift, the glides, the pong rally, the drag beats, the wash lap, the
screenshot intervals and the fades together. It is read per frame rather than
baked into the constants, so flipping the switch lands on the next frame: no
animation restarts, and one part-way through a beat keeps its place.

The trail's stills now age on animation time rather than wall-clock time, so a
still fades at the rate the frame that dropped it is moving. The audio
crossfade is deliberately left out, being tied to the track that is ending.

The flag is saved with the queue, unlike `paused`: it describes the screen the
player is on, which a restart does not change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
2026-08-14 12:53:33 +02:00

301 lines
9.0 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,
DEFAULT_LOW_RES,
} from '/config.js';
let state = {
items: [],
currentId: null,
background: AUTO_BACKGROUND,
animation: AUTO_ANIMATION,
lowRes: DEFAULT_LOW_RES,
paused: false,
};
// 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 speedBox = document.getElementById('speeds');
const playPause = document.getElementById('playpause');
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;
renderTransport();
renderTotals();
}
// --- play / pause -------------------------------------------------------
playPause.addEventListener('click', () => {
socket.send({ type: 'setPaused', paused: !state.paused });
});
/** Mirrors the server's flag rather than toggling locally, so every phone in
* the room agrees about what the player is doing. */
function renderTransport() {
const paused = Boolean(state.paused);
playPause.textContent = paused ? '▶ Play' : '⏸ Pause';
playPause.title = paused ? 'Resume playback' : 'Pause playback';
playPause.classList.toggle('paused', paused);
playPause.disabled = state.items.length === 0;
}
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;
// Paused, the playhead is standing still — extrapolating would run the
// countdown down between reports and then jump it back on the next one.
const elapsed = state.paused ? 0 : (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,
);
// Not a row of names but a two-way switch, built the same way so it lights up
// and travels like the others. The speeds themselves live in config.js.
const renderSpeed = buildSwitcher(
speedBox,
[
[false, 'Normal'],
[true, 'Low-res screen'],
],
'setLowRes',
() => Boolean(state.lowRes),
);
// --- socket -------------------------------------------------------------
function onMessage(msg) {
if (msg.type === 'state') {
state = msg.state;
render();
renderBackground();
renderAnimation();
renderSpeed();
} 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;
}