Let the queue page pin the frame animation
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
This commit is contained in:
+36
-14
@@ -11,6 +11,7 @@ import {
|
||||
BOUNCE_SPEED,
|
||||
CORNER_INTERVAL_SECONDS,
|
||||
CORNER_MARGIN,
|
||||
AUTO_ANIMATION,
|
||||
} from '/config.js';
|
||||
|
||||
const ASPECT = 9 / 16;
|
||||
@@ -261,33 +262,54 @@ export function startCorners(context) {
|
||||
|
||||
// --- registry -----------------------------------------------------------
|
||||
|
||||
export const ANIMATIONS = [
|
||||
{ name: 'bounce', start: startBounce },
|
||||
{ name: 'corners', start: startCorners },
|
||||
];
|
||||
// Keyed by the names in config.js, which is what travels over the socket.
|
||||
export const ANIMATIONS = {
|
||||
bounce: startBounce,
|
||||
corners: startCorners,
|
||||
};
|
||||
|
||||
const NAMES = Object.keys(ANIMATIONS);
|
||||
|
||||
/**
|
||||
* Run one animation at a time, swapping to a freshly picked one on next().
|
||||
* Returns the handle the player drives from its track changes.
|
||||
* Run one animation at a time. In auto mode next() swaps to a freshly picked
|
||||
* one on every track change; pinned to a name (from the queue page, for
|
||||
* testing) it stays put until the mode changes again.
|
||||
*/
|
||||
export function startAnimationCycle(context) {
|
||||
const picker = createPicker(ANIMATIONS.length);
|
||||
export function startAnimationCycle(context, mode = AUTO_ANIMATION) {
|
||||
const picker = createPicker(NAMES.length);
|
||||
let stop = null;
|
||||
let current = null;
|
||||
|
||||
function next() {
|
||||
function run(name) {
|
||||
stop?.();
|
||||
current = ANIMATIONS[picker.next()];
|
||||
stop = current.start(context);
|
||||
return current.name;
|
||||
current = name;
|
||||
stop = ANIMATIONS[name](context);
|
||||
return current;
|
||||
}
|
||||
|
||||
next();
|
||||
function next() {
|
||||
// Pinned: the track changed, but the operator asked for this one.
|
||||
if (mode !== AUTO_ANIMATION) return current;
|
||||
return run(NAMES[picker.next()]);
|
||||
}
|
||||
|
||||
function setMode(value) {
|
||||
mode = ANIMATIONS[value] ? value : AUTO_ANIMATION;
|
||||
// Always restarts, so switching back to auto takes effect immediately
|
||||
// rather than waiting out the current track.
|
||||
return mode === AUTO_ANIMATION ? run(NAMES[picker.next()]) : run(mode);
|
||||
}
|
||||
|
||||
setMode(mode);
|
||||
|
||||
return {
|
||||
next,
|
||||
setMode,
|
||||
get mode() {
|
||||
return mode;
|
||||
},
|
||||
get name() {
|
||||
return current?.name ?? null;
|
||||
return current;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { connect } from '/shared/socket.js';
|
||||
import { OVERLAP_SECONDS, HARD_CUT_SECONDS, BACKGROUNDS } from '/config.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' };
|
||||
let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION };
|
||||
let decks = [];
|
||||
let active = 0;
|
||||
let transition = null; // { until, duration, from, to } while crossfading
|
||||
@@ -74,7 +74,7 @@ async function start() {
|
||||
animationContext.capture = capture;
|
||||
});
|
||||
|
||||
animations = startAnimationCycle(animationContext);
|
||||
animations = startAnimationCycle(animationContext, state.animation);
|
||||
|
||||
decks = await Promise.all([
|
||||
makeDeck('deck-a', 0),
|
||||
@@ -325,8 +325,10 @@ initProbe();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -39,8 +39,14 @@
|
||||
<p id="empty">Nothing queued yet.</p>
|
||||
|
||||
<footer>
|
||||
<span class="label">Background</span>
|
||||
<div id="backgrounds"></div>
|
||||
<div class="switcher">
|
||||
<span class="label">Background</span>
|
||||
<div id="backgrounds"></div>
|
||||
</div>
|
||||
<div class="switcher">
|
||||
<span class="label">Animation</span>
|
||||
<div id="animations"></div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script type="module" src="/queue/queue.js"></script>
|
||||
|
||||
+11
-3
@@ -241,13 +241,20 @@ footer {
|
||||
border-top: 1px solid #331630;
|
||||
}
|
||||
|
||||
#backgrounds {
|
||||
footer .switcher + .switcher {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
#backgrounds,
|
||||
#animations {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#backgrounds button {
|
||||
#backgrounds button,
|
||||
#animations button {
|
||||
font: inherit;
|
||||
padding: 8px 18px;
|
||||
border-radius: 999px;
|
||||
@@ -257,7 +264,8 @@ footer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#backgrounds button.active {
|
||||
#backgrounds button.active,
|
||||
#animations button.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--ink);
|
||||
|
||||
+43
-16
@@ -1,7 +1,7 @@
|
||||
import { connect, formatDuration } from '/shared/socket.js';
|
||||
import { BACKGROUNDS } from '/config.js';
|
||||
import { BACKGROUNDS, ANIMATIONS, AUTO_ANIMATION } from '/config.js';
|
||||
|
||||
let state = { items: [], currentId: null, background: 'pink' };
|
||||
let state = { items: [], currentId: null, background: 'pink', animation: AUTO_ANIMATION };
|
||||
// Last playhead report, kept with its arrival time so the countdown can be
|
||||
// extrapolated smoothly between the 1 Hz updates.
|
||||
let progress = { id: null, currentTime: 0, at: 0 };
|
||||
@@ -11,6 +11,7 @@ const empty = document.getElementById('empty');
|
||||
const errorBox = document.getElementById('error');
|
||||
const statusBox = document.getElementById('status');
|
||||
const backgroundBox = document.getElementById('backgrounds');
|
||||
const animationBox = document.getElementById('animations');
|
||||
|
||||
const socket = connect({ onMessage, onStatus });
|
||||
|
||||
@@ -183,25 +184,50 @@ function clearDropMarkers() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- background switcher ------------------------------------------------
|
||||
// --- background / animation switchers -----------------------------------
|
||||
|
||||
const backgroundButtons = new Map();
|
||||
/**
|
||||
* A row of buttons that sets one field of the shared state. Returns a render
|
||||
* function that lights up whichever key the server currently reports.
|
||||
*/
|
||||
function buildSwitcher(box, options, type, currentKey) {
|
||||
const buttons = new Map();
|
||||
|
||||
for (const [key, { label }] of Object.entries(BACKGROUNDS)) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = label;
|
||||
button.addEventListener('click', () => socket.send({ type: 'setBackground', key }));
|
||||
backgroundBox.appendChild(button);
|
||||
backgroundButtons.set(key, button);
|
||||
}
|
||||
|
||||
function renderBackground() {
|
||||
for (const [key, button] of backgroundButtons) {
|
||||
button.classList.toggle('active', key === state.background);
|
||||
for (const [key, label] of options) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = label;
|
||||
button.addEventListener('click', () => socket.send({ type, key }));
|
||||
box.appendChild(button);
|
||||
buttons.set(key, button);
|
||||
}
|
||||
|
||||
return function render() {
|
||||
for (const [key, button] of buttons) {
|
||||
button.classList.toggle('active', key === currentKey());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const renderBackground = buildSwitcher(
|
||||
backgroundBox,
|
||||
Object.entries(BACKGROUNDS).map(([key, { label }]) => [key, label]),
|
||||
'setBackground',
|
||||
() => state.background,
|
||||
);
|
||||
|
||||
// Auto leads: it is the normal party setting, the named ones are for testing
|
||||
// a single animation without waiting for the cycle to land on it.
|
||||
const renderAnimation = buildSwitcher(
|
||||
animationBox,
|
||||
[
|
||||
[AUTO_ANIMATION, 'Auto'],
|
||||
...Object.entries(ANIMATIONS).map(([key, { label }]) => [key, label]),
|
||||
],
|
||||
'setAnimation',
|
||||
() => state.animation,
|
||||
);
|
||||
|
||||
// --- socket -------------------------------------------------------------
|
||||
|
||||
function onMessage(msg) {
|
||||
@@ -209,6 +235,7 @@ function onMessage(msg) {
|
||||
state = msg.state;
|
||||
render();
|
||||
renderBackground();
|
||||
renderAnimation();
|
||||
} else if (msg.type === 'progress') {
|
||||
progress = { id: msg.id, currentTime: msg.currentTime, at: Date.now() };
|
||||
renderTotals();
|
||||
|
||||
Reference in New Issue
Block a user