// 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;