440 lines
13 KiB
JavaScript
440 lines
13 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,
|
|
paused: false,
|
|
};
|
|
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 });
|
|
|
|
// 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);
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
transition.startedAt += performance.now() - transition.pausedAt;
|
|
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;
|
|
}
|
|
|
|
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 (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 || transition.pausedAt) 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;
|
|
const previousPaused = state.paused;
|
|
state = msg.state;
|
|
if (state.background !== previousBackground) backgrounds.setMode(state.background);
|
|
if (state.animation !== previousAnimation) animations?.setMode(state.animation);
|
|
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 = {
|
|
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,
|
|
};
|
|
},
|
|
};
|