Firefox has no tab capture, so preferCurrentTab is silently dropped and the operator can only share a window or a screen. Neither maps onto the viewport the way the crop assumes, so screenshots came out offset by the browser chrome — and every failure was swallowed by a bare catch, leaving no way to tell why. Check the shared surface and only crop when the viewport covers it: exact for Chrome's tab capture, true for a screen while the page is fullscreen. Test that by dimensions rather than document.fullscreenElement, which F11 leaves null. Start now requests fullscreen, after getDisplayMedia so the click's transient activation goes to the fragile call. Reasons surface in a corner notice and the console, and the check reruns per grab so leaving fullscreen says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
386 lines
11 KiB
JavaScript
386 lines
11 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 } 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,
|
|
);
|
|
|
|
// --- 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: getDisplayMedia 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.
|
|
const capturing = requestCapture({ onIssue: showNotice });
|
|
|
|
// Screenshots only line up while the viewport covers the whole captured
|
|
// surface. Firefox cannot share a bare tab, so the operator shares the screen
|
|
// and this makes the page match it. Harmless on the tab-capture path.
|
|
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,
|
|
};
|
|
},
|
|
};
|