Waiting for the shuffle bag to land on the animation you are working on makes testing one of them tedious. The queue page now carries an Animation row next to Background: Auto keeps the per-track cycle, any other button pins the player to that animation until the mode changes again. The animation names live in config.js alongside their labels, so the server can validate them and the queue page can label its buttons; motion.js keys its registry by the same names. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
362 lines
10 KiB
JavaScript
362 lines
10 KiB
JavaScript
import { connect } from '/shared/socket.js';
|
|
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS, AUTO_ANIMATION } from '/config.js';
|
|
import { createPlayer, loadApi, STATE } from './yt.js';
|
|
import { startAnimationCycle } from './motion.js';
|
|
import { requestCapture } from './capture.js';
|
|
|
|
const TICK_MS = 250;
|
|
|
|
let state = { items: [], currentId: null, background: 'pink', 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 --------------------------------------------------------
|
|
|
|
const backgroundLayer = document.getElementById('backgrounds');
|
|
const backgroundImages = new Map();
|
|
|
|
for (const [key, { src }] of Object.entries(BACKGROUNDS)) {
|
|
const img = document.createElement('img');
|
|
img.src = src;
|
|
img.alt = '';
|
|
backgroundLayer.appendChild(img);
|
|
backgroundImages.set(key, img);
|
|
}
|
|
|
|
function applyBackground(key) {
|
|
for (const [name, img] of backgroundImages) img.classList.toggle('active', name === key);
|
|
}
|
|
|
|
applyBackground(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.
|
|
animations?.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;
|
|
state = msg.state;
|
|
applyBackground(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;
|
|
},
|
|
get state() {
|
|
return {
|
|
state,
|
|
active,
|
|
transition,
|
|
decks: decks.map((d) => d.itemId),
|
|
animation: animations?.name ?? null,
|
|
};
|
|
},
|
|
};
|