Files
party-player/public/player/player.js
T
simonandClaude Opus 5 7706fe46c6 Lay the party logo over the background
Centred, spanning 60% of the screen width, on the poster's layer so the
video frame and the stills still pass in front of it. The file lives in
assets/ with the posters, so it has to be kept out of the background
rotation — it is the one image there that is never a background.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 23:46:19 +02:00

390 lines
12 KiB
JavaScript

import { connect } from '/shared/socket.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, AUTO_BACKGROUND, AUTO_ANIMATION } from '/config.js';
import { createPlayer, loadApi, STATE } from './yt.js';
import { startAnimationCycle } 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 };
let decks = [];
let active = 0;
let transition = null; // { until, duration, from, to } while crossfading
let started = false;
let animations = null;
const socket = connect({ onMessage, onStatus });
// --- 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 });
// Screenshots only line up while the viewport covers the whole screen, since
// that is what makes a CSS pixel a screen pixel. Chrome's tab capture is the
// one exception, and fullscreen does it no harm.
document.documentElement.requestFullscreen?.().catch(() => {
showNotice('Could not go fullscreen — press F11 so screenshots line up.');
});
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 });
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;
// New track, new animation, new poster and crop.
animations?.next();
backgrounds.next();
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;
const previousAnimation = state.animation;
const previousBackground = state.background;
state = msg.state;
if (state.background !== previousBackground) backgrounds.setMode(state.background);
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
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 = {
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);
},
nextAnimation() {
return animations?.next() ?? null;
},
nextBackground() {
return backgrounds.next();
},
get state() {
return {
state,
active,
transition,
decks: decks.map((d) => d.itemId),
animation: animations?.name ?? null,
background: backgrounds.key,
};
},
};