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, }; }, };