The lettering does not sit in the middle of its own image, so centring the image leaves the logo looking pushed to one side. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
178 lines
7.3 KiB
JavaScript
178 lines
7.3 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;
|
||
|
||
// 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']);
|
||
|
||
// 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;
|