The poster behind the frame was one of two, picked by hand and then left there all night. It now turns over on every track change the way the animations do, and never as the same picture twice: a shuffle bag over whatever images sit in assets/, each shown at a random zoom between 1.15 and 1.9 and cropped around a randomly chosen point of the poster. The list is read from the directory rather than written out in config.js. The old one named two files that are no longer there, which is the failure mode a hand-kept list has; now a poster joins the rotation, with its own button on the queue page, by being dropped in the folder. Zoom never goes below 1.15. These are portrait prints and the screen is wide, so cover fits the poster's full width and crops vertically — at 1 the paper margin shows as a grey stripe down both sides. 1.15 cuts at least 13% off the width of every crop a 1920x1080 screen can produce. The crop is drawn into a screen-sized canvas rather than handed to CSS as an object-fit rule on a full-size <img>. The scans are 3508x4950, which is a 70MB texture per layer and two of them composited for the length of the fade; instead each pick is fetched, resized off the main thread to roughly screen resolution, drawn once, and closed. A swap costs about 400ms of async work, all of it before the fade begins, and no full-size bitmap is held anywhere. Nothing about the posters' resolution reaches the compositor, so assets/ can keep the 300 dpi originals. Backgrounds pin and unpin like animations: DEFAULT_BACKGROUND is now the auto cycle, the queue page's row leads with Auto, and isBackground() guards the socket handler and the restored queue.json against a key that no longer names a file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
359 lines
10 KiB
JavaScript
359 lines
10 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.
|
|
const capturing = requestCapture();
|
|
|
|
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';
|
|
}
|
|
|
|
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,
|
|
};
|
|
},
|
|
};
|