From 049a2698859d293f83d9f0e804bc6acbdc07200e Mon Sep 17 00:00:00 2001 From: simon Date: Wed, 12 Aug 2026 18:02:01 +0200 Subject: [PATCH] play/pause button --- public/player/player.js | 54 +++++++++++++++++++++++++++++++++++++++-- public/queue/index.html | 4 +++ public/queue/queue.css | 31 +++++++++++++++++++++++ public/queue/queue.js | 30 +++++++++++++++++++++-- server/index.js | 8 ++++++ server/state.js | 3 +++ 6 files changed, 126 insertions(+), 4 deletions(-) diff --git a/public/player/player.js b/public/player/player.js index 4fe9089..1c75f7c 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -7,7 +7,13 @@ import { requestCapture } from './capture.js'; const TICK_MS = 250; -let state = { items: [], currentId: null, background: AUTO_BACKGROUND, animation: AUTO_ANIMATION }; +let state = { + items: [], + currentId: null, + background: AUTO_BACKGROUND, + animation: AUTO_ANIMATION, + paused: false, +}; let decks = []; let active = 0; let transition = null; // { until, duration, from, to } while crossfading @@ -87,6 +93,9 @@ async function start() { const current = currentItem(); if (current) playOn(active, current, { volume: 100, opacity: 1 }); + // Someone may have paused before this page was even opened. + if (state.paused) applyPaused(true); + setInterval(tick, TICK_MS); setInterval(reportProgress, 1000); } @@ -121,7 +130,41 @@ function playOn(index, item, { volume, opacity }) { deck.setOpacity(opacity); } +/** + * Put the decks down or pick them back up. A crossfade in flight is frozen + * rather than abandoned: the ramp runs on wall-clock time, so the paused + * stretch has to be handed back to it on the way out. + */ +function applyPaused(paused) { + if (!decks.length) return; + + if (paused) { + if (transition) transition.pausedAt = performance.now(); + for (const deck of decks) deck.player.pauseVideo(); + return; + } + + if (transition?.pausedAt) { + transition.startedAt += performance.now() - transition.pausedAt; + transition.pausedAt = null; + } + + // Only the decks actually in play — the idle one is stopped, not paused. + if (decks[active].itemId) decks[active].player.playVideo(); + if (transition) { + decks[transition.to].player.playVideo(); + rampTransition(); + } +} + function onDeckState(index, code) { + // A load can outrace the pause that arrived just before it, so a deck that + // starts playing while we are paused has to be put back down. + if (state.paused && code === STATE.PLAYING) { + decks[index]?.player.pauseVideo(); + return; + } + if (index !== active || transition) return; // Safety net: a track that ended without our tick catching it (very short @@ -142,6 +185,11 @@ function onDeckState(index, code) { function tick() { if (!decks.length) return; + // Nothing moves while paused. Above all the crossfade trigger below, which + // reads a playhead that has stopped advancing and would fire at once if the + // pause landed inside the last few seconds of a track. + if (state.paused) return; + if (transition) { stepTransition(); return; @@ -214,7 +262,7 @@ function beginCrossfade(item, seconds, { advance }) { * rAF entirely, and a half-faded handover would leave two tracks audible. */ function stepTransition() { - if (!transition) return false; + if (!transition || transition.pausedAt) return false; const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1); const outgoing = decks[transition.from]; @@ -331,9 +379,11 @@ function onMessage(msg) { const previousAnimation = state.animation; const previousBackground = state.background; + const previousPaused = state.paused; state = msg.state; if (state.background !== previousBackground) backgrounds.setMode(state.background); if (state.animation !== previousAnimation) animations?.setMode(state.animation); + if (state.paused !== previousPaused) applyPaused(state.paused); updateGate(); pumpProbe(); } diff --git a/public/queue/index.html b/public/queue/index.html index e71b9e4..267c4a0 100644 --- a/public/queue/index.html +++ b/public/queue/index.html @@ -35,6 +35,10 @@ +
+ +
+

    Nothing queued yet.

    diff --git a/public/queue/queue.css b/public/queue/queue.css index 4a49d53..fc73824 100644 --- a/public/queue/queue.css +++ b/public/queue/queue.css @@ -129,6 +129,37 @@ h1 { font-weight: 600; } +/* --- transport ---------------------------------------------------------- */ + +#transport { + margin-bottom: 12px; +} + +#playpause { + width: 100%; + font: inherit; + font-weight: 700; + padding: 12px 24px; + border-radius: 10px; + border: 1px solid #43213a; + background: var(--panel); + color: var(--text); + cursor: pointer; +} + +/* Paused is the state that wants acting on, so that is the one wearing the + accent. */ +#playpause.paused { + background: var(--accent); + border-color: var(--accent); + color: var(--ink); +} + +#playpause:disabled { + opacity: 0.4; + cursor: default; +} + /* --- list --------------------------------------------------------------- */ #list { diff --git a/public/queue/queue.js b/public/queue/queue.js index d76a6e0..95cf379 100644 --- a/public/queue/queue.js +++ b/public/queue/queue.js @@ -1,7 +1,13 @@ 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 }; +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 }; @@ -12,6 +18,7 @@ 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 }); @@ -38,9 +45,26 @@ function showError(message) { 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'; @@ -117,7 +141,9 @@ function renderTotals() { /** 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; + // 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); } diff --git a/server/index.js b/server/index.js index fef7711..f99595f 100644 --- a/server/index.js +++ b/server/index.js @@ -136,6 +136,14 @@ const handlers = { commit(); }, + // Broadcast without saving: `paused` is not part of the persisted state. + setPaused(_ws, msg) { + if (typeof msg.paused !== 'boolean') return; + if (store.getState().paused === msg.paused) return; + store.getState().paused = msg.paused; + broadcastState(); + }, + setBackground(_ws, msg) { if (!store.isBackground(msg.key)) return; store.getState().background = msg.key; diff --git a/server/state.js b/server/state.js index ff5040f..8dbe857 100644 --- a/server/state.js +++ b/server/state.js @@ -23,6 +23,9 @@ const state = { currentId: null, background: DEFAULT_BACKGROUND, animation: DEFAULT_ANIMATION, + // Deliberately absent from save(): a restart should bring the music back, + // not leave the party silently paused because of a click hours ago. + paused: false, }; export function getState() {