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