280 lines
8.5 KiB
JavaScript
280 lines
8.5 KiB
JavaScript
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,
|
||
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 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,
|
||
);
|
||
|
||
// --- 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;
|
||
}
|