Files
party-player/public/player/player.js
T
SiandClaude Opus 5 176ca6868f Close the three seams in the crossfade
Track changes were audible: a gap in the audio, a clipped ending, and the
outgoing video briefly restarting. Three separate causes, all in the handover.

The pointer race is what restarts the video. finishTransition sends trackEnded
and the server only moves currentId once it has processed it, so for one round
trip state.currentId still names the track that just faded out — which is still
in items. The next tick, at most 250ms later, reads that as "the pointer moved
somewhere we are not playing" and hard-cuts back to it from zero; the real
broadcast then arrives and cuts forward again, restarting the new track too.
The player now advances its own pointer as it sends and reconciles against the
broadcast, keeping its version while the server still names the finished track
and yielding to anything else, which is someone jumping.

The fade outlasted its own track. It was triggered at OVERLAP_SECONDS left but
ran for a fixed OVERLAP_SECONDS, and the tick that fires it is already up to a
quarter second late — getDuration() also reports a length that runs a little
past the last of the audio. So the outgoing track always ran out mid-fade: the
ending is clipped, and YouTube puts up its end screen, which shows the video's
first frame and reads as the track starting over. The fade now runs for what is
actually left, less END_GUARD_SECONDS, so it lands before the track does.

The gap is the incoming deck buffering on the clock. beginCrossfade loaded the
next video and started a wall-clock ramp against it, so the outgoing track was
ducked while the incoming one was still fetching its first bytes. The next
track is now prerolled PREROLL_SECONDS ahead — loaded, played until YouTube
reports it running, then seeked back to 0 and paused, which is what keeps the
fade opening on the first bar rather than four seconds in — and the ramp does
not open until the incoming deck is genuinely playing. START_WAIT_SECONDS caps
that wait so a video that never loads cannot strand a track that is ending.

Not verified against live playback: the browser the automation drives renders
its tab offscreen, permanently visibilityState "hidden", and Chrome will not
start a YouTube video there — the deck loads and reports its duration, but the
playhead never leaves zero. Confirmed only that the page loads and the decks
build. __player.state gains playhead, preroll and handover, and seekToEnd()
now lands before the preroll window so one call exercises the whole handover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
2026-08-14 13:40:36 +02:00

574 lines
19 KiB
JavaScript

import { connect } from '/shared/socket.js';
import {
OVERLAP_SECONDS,
HARD_CUT_SECONDS,
PREROLL_SECONDS,
END_GUARD_SECONDS,
START_WAIT_SECONDS,
AUTO_BACKGROUND,
AUTO_ANIMATION,
LOW_RES_SPEED,
DEFAULT_LOW_RES,
} from '/config.js';
import { createPlayer, loadApi, STATE } from './yt.js';
import { startAnimationCycle, setSpeed } from './motion.js';
import { startBackgroundCycle, showLogo } from './backdrop.js';
import { requestCapture } from './capture.js';
const TICK_MS = 250;
let state = {
items: [],
currentId: null,
background: AUTO_BACKGROUND,
animation: AUTO_ANIMATION,
lowRes: DEFAULT_LOW_RES,
paused: false,
};
let decks = [];
let active = 0;
let transition = null; // { startedAt, duration, from, to } while crossfading
let preroll = null; // { itemId, deck, ready } while the next track warms up
let handover = null; // { from, to } while the server has yet to confirm an advance
let started = false;
let animations = null;
const socket = connect({ onMessage, onStatus });
// --- speed --------------------------------------------------------------
/**
* Hand the low-res switch to the animations. Only the visuals slow down: the
* audio crossfade is tied to the track that is ending, and stretching that
* would leave two songs overlapping past the end of one of them.
*
* Applied from page load, before anything moves, so the poster's first
* crossfade is already at the right speed.
*/
function applySpeed(lowRes) {
setSpeed(lowRes ? LOW_RES_SPEED : 1);
}
applySpeed(state.lowRes);
// --- backgrounds --------------------------------------------------------
// Runs from page load, not from the start gate: the poster is what is on
// screen while the party waits for someone to click Start.
const backgrounds = startBackgroundCycle(
document.getElementById('backgrounds'),
state.background,
);
showLogo(document.getElementById('logo'));
// --- 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;
// Before any await: the getDisplayMedia fallback needs the click's transient
// activation, which is gone the moment this function yields. It goes first for
// the same reason — it is the call that must not lose the activation to
// another API. The server-screenshot path needs no gesture, but cannot be
// chosen without one, so this ordering has to hold either way.
const capturing = requestCapture({ onIssue: showNotice });
// Cosmetic now rather than required: the server path locates the window
// itself, so screenshots line up windowed too. It is still what you want on a
// projector, so ask — but say nothing if it is refused, since nothing breaks.
// (The getDisplayMedia fallback does still need it; that path says so itself.)
document.documentElement.requestFullscreen?.().catch(() => {});
gate.classList.add('hidden');
// Mutable context: the animations start now rather than stalling behind the
// permission prompt, and read `capture` when they actually take a screenshot.
const animationContext = {
element: document.getElementById('frameMover'),
capture: null,
};
capturing.then((capture) => {
animationContext.capture = capture;
});
animations = startAnimationCycle(animationContext, state.animation);
decks = await Promise.all([
makeDeck('deck-a', 0),
makeDeck('deck-b', 1),
]);
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);
}
// --- 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);
}
/**
* Warm the idle deck up with the track that is coming next: load it, let it
* play far enough to fill its buffer, then park it back at the start.
*
* A deck told to play the instant the crossfade begins spends its first second
* or two fetching, and the fade — which runs on wall-clock time — ramps the
* outgoing track down over that silence. Parking a buffered deck at 0 instead
* means the incoming track is audible on the first frame of the fade.
*/
function prepareNext(item) {
const to = 1 - active;
preroll = { itemId: item.id, deck: to, ready: false };
playOn(to, item, { volume: 0, opacity: 0 });
decks[to].player.playVideo();
}
/**
* 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) {
// Whichever clock is running: before the ramp opens it is the one waiting
// for the incoming deck, after that it is the ramp itself.
const stalled = performance.now() - transition.pausedAt;
if (transition.startedAt === null) transition.waitingSince += stalled;
else transition.startedAt += stalled;
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;
}
// The prerolled deck has buffered enough to be playing; that is all the
// preroll was after. Park it back at the top so the fade opens on the first
// bar of the song rather than four seconds into it — resuming from here costs
// nothing, the data is already in hand.
if (preroll && !preroll.ready && index === preroll.deck && code === STATE.PLAYING) {
preroll.ready = true;
decks[index].player.seekTo(0, true);
decks[index].player.pauseVideo();
return;
}
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;
// 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;
}
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 (preroll) {
decks[preroll.deck].player.stopVideo();
decks[preroll.deck].itemId = null;
preroll = null;
}
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;
const remaining = duration - currentTime;
if (remaining > OVERLAP_SECONDS + PREROLL_SECONDS) return;
const next = nextItem();
// A single-item queue loops instead of crossfading with itself.
if (!next || next.id === deck.itemId) return;
// Warm the next track up early — and again if the queue was reordered under
// us and the track that is coming is no longer the one on the idle deck.
if (preroll?.itemId !== next.id) prepareNext(next);
if (remaining <= OVERLAP_SECONDS) {
// Fade over what is actually left rather than over the nominal overlap: the
// tick that fires this is already a moment late, and a fade that outlasts
// its own track ends in silence. The floor keeps a throttled tab, which can
// land here with no time left at all, from cutting instantly.
const window = Math.max(remaining - END_GUARD_SECONDS, HARD_CUT_SECONDS);
beginCrossfade(next, window, { advance: true });
}
}
function beginCrossfade(item, seconds, { advance }) {
const from = active;
const to = 1 - active;
// Reuse the prerolled deck if it is holding this very track, buffered and
// parked at 0. Anything else — a hard cut, a jump — loads from cold.
if (preroll?.itemId !== item.id || preroll.deck !== to) {
playOn(to, item, { volume: 0, opacity: 0 });
} else if (!preroll.ready && (decks[to].player.getCurrentTime?.() ?? 0) > 0.5) {
// Warmed but never parked, because a pause landed inside the preroll. It is
// some seconds into the song; the fade still has to open on the first bar.
// (A preroll that has simply not started yet is already at 0 — leave it be,
// seeking a player that is still loading only sets the load back.)
decks[to].player.seekTo(0, true);
}
preroll = null;
decks[to].player.playVideo();
transition = {
// Opened by stepTransition once the incoming deck is actually running: a
// ramp that starts against a deck still spinning up fades the party into
// silence for as long as the load takes.
startedAt: null,
waitingSince: 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 || transition.pausedAt) return false;
const outgoing = decks[transition.from];
const incoming = decks[transition.to];
// Hold the outgoing track at full volume until the incoming one is really
// playing. Normally the preroll has it parked and ready, so this holds for a
// frame or two at most; the cap is there so a video that never loads cannot
// strand the party on a track that is about to end.
if (transition.startedAt === null) {
const playing = incoming.player.getPlayerState?.() === STATE.PLAYING;
const waited = performance.now() - transition.waitingSince;
if (!playing && waited < START_WAIT_SECONDS * 1000) return true;
transition.startedAt = performance.now();
}
const progress = Math.min((performance.now() - transition.startedAt) / transition.duration, 1);
// 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;
// New track, new animation, new poster and crop.
animations?.next();
backgrounds.next();
if (advance) {
// Move the pointer locally, ahead of the server's answer. The server only
// advances it when it has processed `trackEnded`, and until that broadcast
// arrives `state.currentId` still names the track we just faded out — which
// the jump check in tick() reads as "someone moved the pointer" and honours
// by cutting straight back to it, restarting the track that just ended.
handover = { from: outgoingId, to: decks[to].itemId };
state.currentId = handover.to;
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;
const previousAnimation = state.animation;
const previousBackground = state.background;
const previousPaused = state.paused;
const previousLowRes = state.lowRes;
state = msg.state;
// Keep our own hand-over until the server has caught up with it. A broadcast
// sent before `trackEnded` landed — or triggered by something else entirely,
// like someone adding a video mid-fade — still carries the old pointer.
if (handover) {
if (state.currentId === handover.to) handover = null; // confirmed
else if (state.currentId === handover.from) state.currentId = handover.to;
else handover = null; // the pointer went somewhere else; that beats ours
}
if (state.background !== previousBackground) backgrounds.setMode(state.background);
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
// No restart: the running animation picks the new speed up on its next frame.
if (state.lowRes !== previousLowRes) applySpeed(state.lowRes);
if (state.paused !== previousPaused) applyPaused(state.paused);
updateGate();
pumpProbe();
}
function onStatus(status) {
document.getElementById('status').hidden = status === 'online';
}
// --- notices ------------------------------------------------------------
// Capture problems used to be swallowed silently, which left a broken
// screenshot and no way to tell why. They are advisory, never fatal, so the
// notice fades itself out rather than sitting over the party all night.
const NOTICE_MS = 12000;
let noticeTimer = null;
function showNotice(message) {
console.warn('[player]', message);
const notice = document.getElementById('notice');
notice.textContent = message;
notice.hidden = false;
clearTimeout(noticeTimer);
noticeTimer = setTimeout(() => {
notice.hidden = true;
}, NOTICE_MS);
}
updateGate();
// Debug helper used when verifying the crossfade without waiting out a track.
window.__player = {
// Lands before the preroll window, so the hand-over is exercised in full.
seekToEnd(secondsBefore = OVERLAP_SECONDS + PREROLL_SECONDS + 2) {
const deck = decks[active];
const duration = deck?.player.getDuration?.() ?? 0;
if (duration) deck.player.seekTo(Math.max(0, duration - secondsBefore), true);
},
nextAnimation() {
return animations?.next() ?? null;
},
nextBackground() {
return backgrounds.next();
},
get state() {
const deck = decks[active];
return {
state,
active,
playhead: {
currentTime: deck?.player.getCurrentTime?.() ?? null,
duration: deck?.player.getDuration?.() ?? null,
},
transition,
preroll,
handover,
decks: decks.map((d) => d.itemId),
animation: animations?.name ?? null,
background: backgrounds.key,
};
},
};