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; }