play/pause button
This commit is contained in:
+52
-2
@@ -7,7 +7,13 @@ import { requestCapture } from './capture.js';
|
|||||||
|
|
||||||
const TICK_MS = 250;
|
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 decks = [];
|
||||||
let active = 0;
|
let active = 0;
|
||||||
let transition = null; // { until, duration, from, to } while crossfading
|
let transition = null; // { until, duration, from, to } while crossfading
|
||||||
@@ -87,6 +93,9 @@ async function start() {
|
|||||||
const current = currentItem();
|
const current = currentItem();
|
||||||
if (current) playOn(active, current, { volume: 100, opacity: 1 });
|
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(tick, TICK_MS);
|
||||||
setInterval(reportProgress, 1000);
|
setInterval(reportProgress, 1000);
|
||||||
}
|
}
|
||||||
@@ -121,7 +130,41 @@ function playOn(index, item, { volume, opacity }) {
|
|||||||
deck.setOpacity(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) {
|
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;
|
if (index !== active || transition) return;
|
||||||
|
|
||||||
// Safety net: a track that ended without our tick catching it (very short
|
// Safety net: a track that ended without our tick catching it (very short
|
||||||
@@ -142,6 +185,11 @@ function onDeckState(index, code) {
|
|||||||
function tick() {
|
function tick() {
|
||||||
if (!decks.length) return;
|
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) {
|
if (transition) {
|
||||||
stepTransition();
|
stepTransition();
|
||||||
return;
|
return;
|
||||||
@@ -214,7 +262,7 @@ function beginCrossfade(item, seconds, { advance }) {
|
|||||||
* rAF entirely, and a half-faded handover would leave two tracks audible.
|
* rAF entirely, and a half-faded handover would leave two tracks audible.
|
||||||
*/
|
*/
|
||||||
function stepTransition() {
|
function stepTransition() {
|
||||||
if (!transition) return false;
|
if (!transition || transition.pausedAt) return false;
|
||||||
|
|
||||||
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
|
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
|
||||||
const outgoing = decks[transition.from];
|
const outgoing = decks[transition.from];
|
||||||
@@ -331,9 +379,11 @@ function onMessage(msg) {
|
|||||||
|
|
||||||
const previousAnimation = state.animation;
|
const previousAnimation = state.animation;
|
||||||
const previousBackground = state.background;
|
const previousBackground = state.background;
|
||||||
|
const previousPaused = state.paused;
|
||||||
state = msg.state;
|
state = msg.state;
|
||||||
if (state.background !== previousBackground) backgrounds.setMode(state.background);
|
if (state.background !== previousBackground) backgrounds.setMode(state.background);
|
||||||
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
|
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
|
||||||
|
if (state.paused !== previousPaused) applyPaused(state.paused);
|
||||||
updateGate();
|
updateGate();
|
||||||
pumpProbe();
|
pumpProbe();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="transport">
|
||||||
|
<button id="playpause" type="button">⏸ Pause</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ol id="list"></ol>
|
<ol id="list"></ol>
|
||||||
<p id="empty">Nothing queued yet.</p>
|
<p id="empty">Nothing queued yet.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,37 @@ h1 {
|
|||||||
font-weight: 600;
|
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 --------------------------------------------------------------- */
|
||||||
|
|
||||||
#list {
|
#list {
|
||||||
|
|||||||
+28
-2
@@ -1,7 +1,13 @@
|
|||||||
import { connect, formatDuration } from '/shared/socket.js';
|
import { connect, formatDuration } from '/shared/socket.js';
|
||||||
import { BACKGROUNDS, AUTO_BACKGROUND, ANIMATIONS, AUTO_ANIMATION } from '/config.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
|
// Last playhead report, kept with its arrival time so the countdown can be
|
||||||
// extrapolated smoothly between the 1 Hz updates.
|
// extrapolated smoothly between the 1 Hz updates.
|
||||||
let progress = { id: null, currentTime: 0, at: 0 };
|
let progress = { id: null, currentTime: 0, at: 0 };
|
||||||
@@ -12,6 +18,7 @@ const errorBox = document.getElementById('error');
|
|||||||
const statusBox = document.getElementById('status');
|
const statusBox = document.getElementById('status');
|
||||||
const backgroundBox = document.getElementById('backgrounds');
|
const backgroundBox = document.getElementById('backgrounds');
|
||||||
const animationBox = document.getElementById('animations');
|
const animationBox = document.getElementById('animations');
|
||||||
|
const playPause = document.getElementById('playpause');
|
||||||
|
|
||||||
const socket = connect({ onMessage, onStatus });
|
const socket = connect({ onMessage, onStatus });
|
||||||
|
|
||||||
@@ -38,9 +45,26 @@ function showError(message) {
|
|||||||
function render() {
|
function render() {
|
||||||
list.replaceChildren(...state.items.map(renderItem));
|
list.replaceChildren(...state.items.map(renderItem));
|
||||||
empty.hidden = state.items.length > 0;
|
empty.hidden = state.items.length > 0;
|
||||||
|
renderTransport();
|
||||||
renderTotals();
|
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) {
|
function renderItem(item, index) {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.className = 'item';
|
li.className = 'item';
|
||||||
@@ -117,7 +141,9 @@ function renderTotals() {
|
|||||||
/** Playhead of the current item, extrapolated since the last report. */
|
/** Playhead of the current item, extrapolated since the last report. */
|
||||||
function playheadFor(item) {
|
function playheadFor(item) {
|
||||||
if (progress.id !== item.id) return 0;
|
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);
|
return Math.min(progress.currentTime + elapsed, item.duration ?? Infinity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,14 @@ const handlers = {
|
|||||||
commit();
|
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) {
|
setBackground(_ws, msg) {
|
||||||
if (!store.isBackground(msg.key)) return;
|
if (!store.isBackground(msg.key)) return;
|
||||||
store.getState().background = msg.key;
|
store.getState().background = msg.key;
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const state = {
|
|||||||
currentId: null,
|
currentId: null,
|
||||||
background: DEFAULT_BACKGROUND,
|
background: DEFAULT_BACKGROUND,
|
||||||
animation: DEFAULT_ANIMATION,
|
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() {
|
export function getState() {
|
||||||
|
|||||||
Reference in New Issue
Block a user