// 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; // 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; // 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']); function readBackgrounds() { let files; try { files = readdirSync(ASSET_DIR); } catch { return {}; } return Object.fromEntries( files .filter((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;