getDisplayMedia costs a picker, a permission, and a "Sharing this tab" bar parked over the projector all night. None of it can be suppressed from the page — the indicator is a security surface with no API, and the Chromium switches that hide it were no help here. The server runs on the machine the screen is plugged into, so it can take the screenshot itself. server/screen.js drives grim for one region and /shot returns the finished crop, which means grab() hands back a URL for an <img> rather than a data URL off a canvas: same synchronous contract, so the animations are untouched, and no tainting to work around either. Coordinates line up because a fullscreen page's CSS pixels are the compositor's logical pixels, which is the space grim's -g takes. The monitor layout comes from hyprctl, so a projector is measured against itself rather than the laptop panel, and -s pins the scale to that monitor: it keeps a mixed-DPI desk deterministic, and it keeps grim native, where resampling costs 440ms against 35ms. Needs a wlroots compositor and grim on PATH; without them the route reports 501 and the player falls back to getDisplayMedia, probed at load because that call cannot be reached through an await without losing transient activation. Also skipped when the page is not on localhost, where the server would be photographing its own screen rather than the viewer's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
388 lines
11 KiB
JavaScript
388 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: 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,
|
|
};
|
|
},
|
|
};
|