Track changes were audible: a gap in the audio, a clipped ending, and the outgoing video briefly restarting. Three separate causes, all in the handover. The pointer race is what restarts the video. finishTransition sends trackEnded and the server only moves currentId once it has processed it, so for one round trip state.currentId still names the track that just faded out — which is still in items. The next tick, at most 250ms later, reads that as "the pointer moved somewhere we are not playing" and hard-cuts back to it from zero; the real broadcast then arrives and cuts forward again, restarting the new track too. The player now advances its own pointer as it sends and reconciles against the broadcast, keeping its version while the server still names the finished track and yielding to anything else, which is someone jumping. The fade outlasted its own track. It was triggered at OVERLAP_SECONDS left but ran for a fixed OVERLAP_SECONDS, and the tick that fires it is already up to a quarter second late — getDuration() also reports a length that runs a little past the last of the audio. So the outgoing track always ran out mid-fade: the ending is clipped, and YouTube puts up its end screen, which shows the video's first frame and reads as the track starting over. The fade now runs for what is actually left, less END_GUARD_SECONDS, so it lands before the track does. The gap is the incoming deck buffering on the clock. beginCrossfade loaded the next video and started a wall-clock ramp against it, so the outgoing track was ducked while the incoming one was still fetching its first bytes. The next track is now prerolled PREROLL_SECONDS ahead — loaded, played until YouTube reports it running, then seeked back to 0 and paused, which is what keeps the fade opening on the first bar rather than four seconds in — and the ramp does not open until the incoming deck is genuinely playing. START_WAIT_SECONDS caps that wait so a video that never loads cannot strand a track that is ending. Not verified against live playback: the browser the automation drives renders its tab offscreen, permanently visibilityState "hidden", and Chrome will not start a YouTube video there — the deck loads and reports its duration, but the playhead never leaves zero. Confirmed only that the page loads and the decks build. __player.state gains playhead, preroll and handover, and seekToEnd() now lands before the preroll window so one call exercises the whole handover. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011vCaS2whbFjExShoPNnS3A
210 lines
9.1 KiB
JavaScript
210 lines
9.1 KiB
JavaScript
// Central knobs for the party player.
|
||
|
||
import { readdirSync } from 'node:fs';
|
||
import { dirname, extname, join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
export const PORT = Number(process.env.PORT) || 3000;
|
||
|
||
// Seconds of overlap between the outgoing and incoming video.
|
||
// Audio and video crossfade over this whole window.
|
||
export const OVERLAP_SECONDS = 5;
|
||
|
||
// How fast the player switches when the user jumps around manually.
|
||
export const HARD_CUT_SECONDS = 0.4;
|
||
|
||
// How long before the crossfade the next video is loaded and buffered on the
|
||
// idle deck. A deck that is still fetching its first bytes plays silence, and
|
||
// the fade would ramp straight over that silence — this is what buys the
|
||
// incoming track enough of a head start to be audible the moment it is needed.
|
||
export const PREROLL_SECONDS = 4;
|
||
|
||
// Landing margin for the crossfade, in seconds. getDuration() is the length of
|
||
// the video, which is not quite the length of the audio: it runs a little past
|
||
// the last of the sound, and it is rounded. Finishing the fade this far before
|
||
// the reported end keeps the outgoing track from running out underneath its own
|
||
// fade — which is heard as a clipped ending and seen as YouTube's end screen
|
||
// (the video's thumbnail, i.e. its first frame) flashing up mid-transition.
|
||
export const END_GUARD_SECONDS = 0.35;
|
||
|
||
// How long a crossfade waits for the incoming deck to actually start before it
|
||
// gives up and fades anyway. Only reached when the preroll did not take — a
|
||
// hard cut, or a video that is slow to load.
|
||
export const START_WAIT_SECONDS = 1.5;
|
||
|
||
// The video frame takes up this fraction of the screen. Keep this below 0.5:
|
||
// the corners animation puts a frame-sized image in all four corners, which
|
||
// only stays overlap-free while two frames fit side by side.
|
||
export const FRAME_SCALE = 0.35;
|
||
|
||
// Pixels per second the frame drifts across the screen.
|
||
export const BOUNCE_SPEED = 120;
|
||
|
||
// The corners animation: seconds between screenshots.
|
||
export const CORNER_INTERVAL_SECONDS = 4;
|
||
|
||
// Gap between the frame and the screen edge, as a fraction of the short side.
|
||
export const CORNER_MARGIN = 0.025;
|
||
|
||
// The drag animation gets its own, roomier base size: it is the only thing on
|
||
// screen while it runs, so it does not have to leave space for a second frame.
|
||
export const DRAG_FRAME_SCALE = 0.6;
|
||
|
||
// The drag animation resizes the frame to somewhere between these multiples of
|
||
// its base size. Above 1 the frame grows past DRAG_FRAME_SCALE, which is
|
||
// fine — nothing else is on screen while this animation runs.
|
||
export const DRAG_MIN_SCALE = 0.55;
|
||
export const DRAG_MAX_SCALE = 1.6;
|
||
|
||
// The trail animation: how fast the frame glides, and how sharply it may turn.
|
||
// The turn cap is what bends the path into an arc — without it the frame would
|
||
// snap onto each new heading and the tail would read as a dotted line.
|
||
export const TRAIL_SPEED = 150;
|
||
export const TRAIL_TURN_RATE = 0.7;
|
||
|
||
// Gap between two dropped stills, in frame widths, and how many the tail holds.
|
||
export const TRAIL_SPACING = 0.6;
|
||
export const TRAIL_LENGTH = 8;
|
||
|
||
// A still's life, from the moment it lands to fully faded.
|
||
export const TRAIL_FADE_SECONDS = 8;
|
||
|
||
// The colour wash: seconds for one full trip round the colour wheel. Slow
|
||
// enough that the screen never looks like it is flashing — the colour should
|
||
// only be obviously different once you look away and back.
|
||
export const WASH_CYCLE_SECONDS = 90;
|
||
|
||
// How far apart the two ends of the wash gradient sit on the wheel, in degrees,
|
||
// and how strongly the wash tints the poster underneath it.
|
||
export const WASH_SPREAD = 70;
|
||
export const WASH_STRENGTH = 0.55;
|
||
|
||
// The wash animation gets its own, roomier frame: like the drag animation it
|
||
// has the screen to itself, so it does not have to leave room for a second one.
|
||
export const WASH_FRAME_SCALE = 0.6;
|
||
|
||
// Pong: the frame is the paddle, so it gets its own size — narrow enough that
|
||
// it can miss, wide enough that the video in it is still worth watching.
|
||
export const PONG_FRAME_SCALE = 0.3;
|
||
|
||
// How fast the ball travels, and how fast the paddle may slide, in pixels per
|
||
// second. The paddle has to outrun the ball to defend a wide screen, but not by
|
||
// so much that it teleports under every shot.
|
||
export const PONG_BALL_SPEED = 420;
|
||
export const PONG_PADDLE_SPEED = 760;
|
||
|
||
// The ball is a square this fraction of the screen's short side.
|
||
export const PONG_BALL_SIZE = 0.028;
|
||
|
||
// Gap between the paddle and the bottom edge, as a fraction of the short side.
|
||
export const PONG_PADDLE_MARGIN = 0.04;
|
||
|
||
// Steepest angle off the paddle, in degrees from straight up. Hitting the ball
|
||
// with the edge of the paddle sends it out this far.
|
||
export const PONG_MAX_ANGLE = 60;
|
||
|
||
// How often the paddle deliberately comes up short, so the counter in the
|
||
// corner actually moves. Without this a paddle that outruns the ball never
|
||
// loses a rally.
|
||
export const PONG_MISS_CHANCE = 0.25;
|
||
|
||
// Seconds the ball waits out of play after it gets past the paddle.
|
||
export const PONG_SERVE_SECONDS = 1.1;
|
||
|
||
// Everything that moves is measured in pixels per second, so the same speed
|
||
// crosses a small screen in less time than a big one: on 1280x800 the frame
|
||
// covers the width in two thirds of the time it takes on 1920x1080, and the
|
||
// whole player reads as hurried. The low-res switch on the queue page multiplies
|
||
// every animation clock — drift, glides, the pong rally, the drag beats, the
|
||
// wash lap, the screenshot intervals and the fades — by this one factor, which
|
||
// is the 1280/1920 ratio rounded off. Lower it to slow the party down further.
|
||
export const LOW_RES_SPEED = 0.65;
|
||
|
||
// Whether the player starts in that mode. Persisted with the queue, so a screen
|
||
// that needs it only has to be told once.
|
||
export const DEFAULT_LOW_RES = false;
|
||
|
||
// The frame animations, keyed by the id sent over the socket. The names must
|
||
// match the registry in public/player/motion.js, which supplies the code.
|
||
export const ANIMATIONS = {
|
||
bounce: { label: 'Bounce' },
|
||
corners: { label: 'Corners' },
|
||
drag: { label: 'Drag & resize' },
|
||
pong: { label: 'Pong' },
|
||
trail: { label: 'Comet trail' },
|
||
wash: { label: 'Colour wash' },
|
||
};
|
||
|
||
// Cycle a fresh random animation on every track change. Anything else pins the
|
||
// player to that one animation, which is what the queue page's buttons are for.
|
||
export const AUTO_ANIMATION = 'auto';
|
||
export const DEFAULT_ANIMATION = AUTO_ANIMATION;
|
||
|
||
// Selectable fullscreen backgrounds: whatever images sit in assets/, keyed by
|
||
// filename. Read from the directory rather than listed here so that dropping a
|
||
// poster in is all it takes — the list travels to both frontends over the
|
||
// generated /config.js.
|
||
const ASSET_DIR = join(dirname(fileURLToPath(import.meta.url)), 'assets');
|
||
const IMAGE_TYPES = new Set(['.jpg', '.jpeg', '.png', '.webp', '.avif']);
|
||
|
||
// The party's logo, laid over every background. Lives in assets/ with the
|
||
// posters, so it has to be kept out of the rotation below — it is the one
|
||
// image there that is never a background of its own.
|
||
const LOGO_FILE = 'logo.png';
|
||
export const LOGO_SRC = `/assets/${LOGO_FILE}`;
|
||
|
||
// How much of the screen's width the logo spans.
|
||
export const LOGO_WIDTH = 0.6;
|
||
|
||
// The lettering does not sit in the middle of its own image, so centring the
|
||
// image leaves the logo looking pushed to one side. This nudges it back, as a
|
||
// fraction of the logo's width — positive moves it right.
|
||
export const LOGO_OFFSET = 0.1;
|
||
|
||
function readBackgrounds() {
|
||
let files;
|
||
try {
|
||
files = readdirSync(ASSET_DIR);
|
||
} catch {
|
||
return {};
|
||
}
|
||
|
||
return Object.fromEntries(
|
||
files
|
||
.filter((file) => file !== LOGO_FILE && IMAGE_TYPES.has(extname(file).toLowerCase()))
|
||
.sort()
|
||
.map((file) => {
|
||
const key = file.slice(0, -extname(file).length);
|
||
const label = key.charAt(0).toUpperCase() + key.slice(1);
|
||
return [key, { label, src: `/assets/${encodeURIComponent(file)}` }];
|
||
}),
|
||
);
|
||
}
|
||
|
||
export const BACKGROUNDS = readBackgrounds();
|
||
|
||
// Cycle a fresh random background — and a fresh crop of it — on every track
|
||
// change, the way the animations rotate. Anything else pins the player to that
|
||
// one image, which is what the queue page's buttons are for.
|
||
export const AUTO_BACKGROUND = 'auto';
|
||
export const DEFAULT_BACKGROUND = AUTO_BACKGROUND;
|
||
|
||
// How far into a background a crop may zoom. The posters are portrait and the
|
||
// screen is wide, so a crop is mostly a question of which horizontal band of
|
||
// the poster to keep. The minimum sits above 1 because at exactly 1 a wide
|
||
// screen shows the poster's full width — paper margin and all, which reads as
|
||
// a grey stripe down each side.
|
||
export const BACKGROUND_MIN_ZOOM = 1.15;
|
||
export const BACKGROUND_MAX_ZOOM = 1.9;
|
||
|
||
// How much of the image the focal point may sit away from the centre, as a
|
||
// fraction of the full 0–100% range. Kept below 1 because a focal point right
|
||
// on an edge crops to the poster's margin, which is just empty paper.
|
||
export const BACKGROUND_FOCUS_SPREAD = 0.7;
|
||
|
||
// How many source pixels a crop may carry per screen pixel. The posters are
|
||
// 300 dpi print scans — far more detail than any screen shows — and the whole
|
||
// point of resizing them before drawing is to keep the crossfade cheap. A
|
||
// little above 1:1 keeps a steep zoom from looking soft.
|
||
export const BACKGROUND_MAX_OVERSAMPLE = 1.3;
|