Files
party-player/public/player/player.js
T
simonandClaude Opus 5 ce1a6d6a47 Cycle the frame through animations, add a corners effect
The frame had exactly one behaviour, started once and run forever. Make
animations a start(context) => stop() lifecycle held in a registry, and
draw a new one on every completed crossfade.

Selection uses a shuffle bag: each animation comes up exactly once per
pass, so they stay evenly used, and a swap at the pass boundary keeps the
same one from running twice in a row. Written for a registry larger than
today's two entries.

The new corners animation parks the frame in a random corner and drops a
screenshot of it into the next of the other three corners every 5s, going
clockwise. The frame's own corner is never a slot, so a still cannot
cover it by construction rather than by clamping. This holds while
FRAME_SCALE stays under 0.5, now noted where that knob lives.

Screenshots need getDisplayMedia: the decks are cross-origin YouTube
iframes, so canvas taints and there is no other way to read the playing
pixels. The prompt has to be requested before start() yields, or the
click's transient activation is gone. Declining, or serving over plain
HTTP where mediaDevices is undefined, leaves the frame parked without
stills instead of failing.

The capture sink is a 1x1 element in the document, like #probe: detached
video elements are never composited and get throttled hard enough to make
every screenshot seconds stale.

startBounce also gains the stop() it always needed — it was leaking its
rAF loop and resize listener, and a queued frame would fight whichever
animation came next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tg15tLAuT6NrCE1ZxKVgDB
2026-08-09 22:04:33 +02:00

360 lines
9.9 KiB
JavaScript

import { connect } from '/shared/socket.js';
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } 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' };
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);
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;
state = msg.state;
applyBackground(state.background);
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,
};
},
};