Add party player: YouTube queue with crossfading playback

Two frontends sharing one WebSocket-backed state on the server:

- Player: fullscreen poster background with a small frame (30% of the
  screen) that drifts diagonally and reflects off the edges. Two stacked
  YouTube players hand over with a 5s equal-power crossfade so there is
  never silence between tracks.
- Queue: add by URL, drag to reorder, delete, click to jump. Shows title
  and length per item, highlights what is playing, and tracks remaining
  vs. total playtime.

No API key is required — titles come from YouTube's oEmbed endpoint and
durations from a hidden muted probe player that cues each new video just
to read its length.

The fade is driven by both requestAnimationFrame and the interval tick:
a hidden or throttled tab stops serving frames entirely, which would
otherwise strand a handover half-faded with two tracks audible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
This commit is contained in:
simon
2026-08-09 21:22:13 +02:00
co-authored by Claude Opus 5
commit ad999e1c3e
17 changed files with 2540 additions and 0 deletions
+330
View File
@@ -0,0 +1,330 @@
import { connect } from '/shared/socket.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.js';
import { createPlayer, loadApi, STATE } from './yt.js';
import { startBounce } from './motion.js';
const TICK_MS = 250;
let state = { items: [], currentId: null, background: 'pink' };
let decks = [];
let active = 0;
let transition = null; // { until, duration, from, to } while crossfading
let started = false;
const socket = connect({ onMessage, onStatus });
// --- backgrounds --------------------------------------------------------
const backgroundLayer = document.getElementById('backgrounds');
const backgroundImages = new Map();
for (const [key, { src }] of Object.entries(BACKGROUNDS)) {
const img = document.createElement('img');
img.src = src;
img.alt = '';
backgroundLayer.appendChild(img);
backgroundImages.set(key, img);
}
function applyBackground(key) {
for (const [name, img] of backgroundImages) img.classList.toggle('active', name === key);
}
applyBackground(state.background);
// --- start gate ---------------------------------------------------------
const gate = document.getElementById('gate');
const startButton = document.getElementById('start');
const gateHint = document.getElementById('gate-hint');
startButton.addEventListener('click', start);
gate.addEventListener('click', (event) => {
if (event.target === gate && !startButton.disabled) start();
});
function updateGate() {
if (started) return;
const ready = state.items.length > 0;
startButton.disabled = !ready;
gateHint.textContent = ready
? `${state.items.length} video${state.items.length === 1 ? '' : 's'} queued — click to start the music`
: 'Add videos from the queue page to begin.';
}
async function start() {
if (started) return;
started = true;
gate.classList.add('hidden');
startBounce(document.getElementById('frameMover'));
decks = await Promise.all([
makeDeck('deck-a', 0),
makeDeck('deck-b', 1),
]);
const current = currentItem();
if (current) playOn(active, current, { volume: 100, opacity: 1 });
setInterval(tick, TICK_MS);
setInterval(reportProgress, 1000);
}
// --- decks --------------------------------------------------------------
async function makeDeck(elementId, index) {
const element = document.querySelector(`.deck[data-deck="${index === 0 ? 'a' : 'b'}"]`);
const deck = {
element,
itemId: null,
player: null,
setOpacity(value) {
element.style.opacity = String(value);
},
};
deck.player = await createPlayer(elementId, {
onStateChange: (code) => onDeckState(index, code),
});
deck.player.setVolume(0);
deck.setOpacity(0);
return deck;
}
function playOn(index, item, { volume, opacity }) {
const deck = decks[index];
deck.itemId = item.id;
deck.player.loadVideoById(item.videoId);
deck.player.setVolume(volume);
deck.player.unMute();
deck.setOpacity(opacity);
}
function onDeckState(index, code) {
if (index !== active || transition) return;
// Safety net: a track that ended without our tick catching it (very short
// clips, or a stalled tab) still has to hand over.
if (code === STATE.ENDED) {
const next = nextItem();
if (!next || next.id === decks[active].itemId) {
decks[active].player.seekTo(0, true);
decks[active].player.playVideo();
} else {
beginCrossfade(next, HARD_CUT_SECONDS, { advance: true });
}
}
}
// --- playback loop ------------------------------------------------------
function tick() {
if (!decks.length) return;
if (transition) {
stepTransition();
return;
}
const deck = decks[active];
// Queue emptied out from under us — stop rather than loop the video the
// user just deleted.
if (state.items.length === 0) {
if (deck.itemId) {
deck.player.stopVideo();
deck.setOpacity(0);
deck.itemId = null;
}
return;
}
// Nothing playing but the queue has items again (all deleted, then re-added).
if (!deck.itemId) {
const resume = currentItem();
if (resume) playOn(active, resume, { volume: 100, opacity: 1 });
return;
}
// The server pointer moved somewhere we are not playing — a jump, or the
// current item was deleted. Fade there quickly instead of waiting.
if (state.currentId && deck.itemId !== state.currentId) {
const target = state.items.find((item) => item.id === state.currentId);
if (target) {
beginCrossfade(target, HARD_CUT_SECONDS, { advance: false });
return;
}
}
const duration = deck.player.getDuration?.() ?? 0;
const currentTime = deck.player.getCurrentTime?.() ?? 0;
if (!duration) return;
if (duration - currentTime <= OVERLAP_SECONDS) {
const next = nextItem();
// A single-item queue loops instead of crossfading with itself.
if (!next || next.id === deck.itemId) return;
beginCrossfade(next, OVERLAP_SECONDS, { advance: true });
}
}
function beginCrossfade(item, seconds, { advance }) {
const from = active;
const to = 1 - active;
playOn(to, item, { volume: 0, opacity: 0 });
decks[to].player.playVideo();
transition = {
startedAt: performance.now(),
duration: seconds * 1000,
from,
to,
advance,
outgoingId: decks[from].itemId,
};
rampTransition();
}
/**
* Apply the fade for the current point in time. Driven by animation frames for
* smoothness, and also by the tick — a hidden or throttled tab stops serving
* rAF entirely, and a half-faded handover would leave two tracks audible.
*/
function stepTransition() {
if (!transition) return false;
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
const outgoing = decks[transition.from];
const incoming = decks[transition.to];
// Equal-power curve: the combined loudness stays flat through the middle
// of the mix instead of dipping.
outgoing.player.setVolume(Math.round(Math.cos((progress * Math.PI) / 2) * 100));
incoming.player.setVolume(Math.round(Math.sin((progress * Math.PI) / 2) * 100));
outgoing.setOpacity(1 - progress);
incoming.setOpacity(progress);
if (progress < 1) return true;
finishTransition();
return false;
}
function rampTransition() {
if (stepTransition()) requestAnimationFrame(rampTransition);
}
function finishTransition() {
if (!transition) return;
const { from, to, advance, outgoingId } = transition;
transition = null;
decks[from].player.stopVideo();
decks[from].player.setVolume(0);
decks[from].setOpacity(0);
decks[from].itemId = null;
decks[to].player.setVolume(100);
decks[to].setOpacity(1);
active = to;
if (advance) socket.send({ type: 'trackEnded', id: outgoingId });
}
function reportProgress() {
const deck = decks[active];
if (!deck?.itemId || transition) return;
const currentTime = deck.player.getCurrentTime?.() ?? 0;
socket.send({ type: 'progress', id: deck.itemId, currentTime });
}
// --- queue helpers ------------------------------------------------------
function currentItem() {
return state.items.find((item) => item.id === state.currentId) || state.items[0] || null;
}
function nextItem() {
if (state.items.length === 0) return null;
const index = state.items.findIndex((item) => item.id === decks[active]?.itemId);
if (index === -1) return currentItem();
return state.items[(index + 1) % state.items.length];
}
// --- duration probe -----------------------------------------------------
// Cues (never plays) each unmeasured video in a hidden muted player purely to
// read getDuration(), which is the only keyless way to learn a video's length.
let probe = null;
let probing = null;
async function initProbe() {
await loadApi();
probe = await createPlayer('probe-player', { onStateChange: onProbeState });
probe.mute();
probe.setVolume(0);
pumpProbe();
}
function onProbeState(code) {
if (!probing || (code !== STATE.CUED && code !== STATE.PLAYING)) return;
const seconds = probe.getDuration();
if (seconds > 0) {
socket.send({ type: 'duration', id: probing, seconds });
probing = null;
pumpProbe();
}
}
function pumpProbe() {
if (!probe || probing) return;
const pending = state.items.find((item) => item.duration === null);
if (!pending) return;
probing = pending.id;
probe.cueVideoById(pending.videoId);
// If the video is unavailable the CUED event never carries a length; move on
// rather than stalling every later item behind it.
const stuckOn = pending.id;
setTimeout(() => {
if (probing === stuckOn) {
probing = null;
pumpProbe();
}
}, 10000);
}
initProbe();
// --- socket -------------------------------------------------------------
function onMessage(msg) {
if (msg.type !== 'state') return;
state = msg.state;
applyBackground(state.background);
updateGate();
pumpProbe();
}
function onStatus(status) {
document.getElementById('status').hidden = status === 'online';
}
updateGate();
// Debug helper used when verifying the crossfade without waiting out a track.
window.__player = {
seekToEnd(secondsBefore = OVERLAP_SECONDS + 2) {
const deck = decks[active];
const duration = deck?.player.getDuration?.() ?? 0;
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
},
get state() {
return { state, active, transition, decks: decks.map((d) => d.itemId) };
},
};