Files
party-player/README.md
T
simonandClaude Opus 5 db672b8b32 Rotate the background with every track
The poster behind the frame was one of two, picked by hand and then left
there all night. It now turns over on every track change the way the
animations do, and never as the same picture twice: a shuffle bag over
whatever images sit in assets/, each shown at a random zoom between 1.15
and 1.9 and cropped around a randomly chosen point of the poster.

The list is read from the directory rather than written out in config.js.
The old one named two files that are no longer there, which is the failure
mode a hand-kept list has; now a poster joins the rotation, with its own
button on the queue page, by being dropped in the folder.

Zoom never goes below 1.15. These are portrait prints and the screen is
wide, so cover fits the poster's full width and crops vertically — at 1
the paper margin shows as a grey stripe down both sides. 1.15 cuts at
least 13% off the width of every crop a 1920x1080 screen can produce.

The crop is drawn into a screen-sized canvas rather than handed to CSS as
an object-fit rule on a full-size <img>. The scans are 3508x4950, which is
a 70MB texture per layer and two of them composited for the length of the
fade; instead each pick is fetched, resized off the main thread to roughly
screen resolution, drawn once, and closed. A swap costs about 400ms of
async work, all of it before the fade begins, and no full-size bitmap is
held anywhere. Nothing about the posters' resolution reaches the
compositor, so assets/ can keep the 300 dpi originals.

Backgrounds pin and unpin like animations: DEFAULT_BACKGROUND is now the
auto cycle, the queue page's row leads with Auto, and isBackground()
guards the socket handler and the restored queue.json against a key that
no longer names a file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
2026-08-10 20:54:10 +02:00

127 lines
6.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Party Player
A YouTube playlist player for parties. Two browser windows run side by side:
- **Player** (`http://localhost:3000/`) — fullscreen poster background with a
small video frame that moves around. Both the animation (see
[Animations](#animations)) and the poster behind it (see
[Backgrounds](#backgrounds)) change on every track. Consecutive videos
overlap in a 5 second audio/video crossfade, so there is never silence
between tracks.
- **Queue** (`http://localhost:3000/queue`) — add videos by URL, drag to
reorder, delete, see how much playtime is left, and pick the background and
animation.
## Run
```sh
npm install
npm start
```
Put the player on the projector/TV screen (F11 for fullscreen) and open the
queue on a laptop or phone. On the same network, other devices reach both pages
at `http://<this-machine-ip>:3000`.
**The player page opens behind a "Start" button.** Browsers block unmuted
autoplay, so the first click is what unlocks audio. One click per session.
## Notes
- No YouTube API key is needed. Titles come from YouTube's public oEmbed
endpoint; durations are measured by a hidden muted player on the player page,
which is why a freshly added video shows `–:––` for a moment (and keeps
showing it while the player page is closed). Totals are prefixed with `≥`
whenever a length is still unknown.
- The queue is saved to `queue.json` on every change and restored at startup.
- Settings live in `config.js`: overlap length, frame size, drift speed, corner
screenshot interval and margin, the drag animation's size range, the
background zoom range, port, and the animation list. Everything there except
`PORT` is mirrored to the browser by the generated `/config.js` route in
`server/index.js`, so a new constant needs no second list.
## Backgrounds
Every image file in `assets/` is a background — drop one in, restart, and it
joins the rotation with a button on the queue page named after the file. There
is no list to maintain.
Like the animations, the background changes on every track: a different poster,
at a random zoom between 1.15× and 1.9×, cropped around a randomly chosen point
of it, cross-fading over about a second. The posters are portrait prints and
the screen is wide, so the crop is mostly a question of which band of the
poster to keep — the zoom never goes below 1.15×, because at 1× a wide screen
shows the poster's full width, paper margin included.
Each crop is resized off the main thread and drawn into a screen-sized canvas
(`public/player/backdrop.js`), so the cross-fade never has to composite a
17-megapixel layer, and the full-size scans are never held in memory. This is
why `assets/` can hold 300 dpi print files without the player caring.
The queue page's **Background** buttons pin one poster the same way the
animation buttons pin one animation; **Auto** hands selection back to the same
shuffle bag the animations use.
## Animations
The video frame's movement changes with every track. Six animations ship
today:
- **bounce** — diagonal drift, reflecting off the screen edges.
- **corners** — the frame parks in a random corner and every 5 seconds a
screenshot of it appears in the next of the three other corners, going
clockwise. The frame never moves, so a screenshot can never cover it.
- **drag** — a drawn mouse pointer travels to the frame, grabs it near its top
edge and drags it across the screen, lets go for a beat, then takes hold of
one corner and pulls the frame to a new size. The aspect ratio stays locked
and the frame stays on screen, so the resize reads as a window resize rather
than a stretch.
- **pong** — the 1972 game with the frame cast as the paddle: it slides along
the bottom of the screen and nowhere else, a square ball runs the diagonals
off the walls, and where the ball lands on the frame's face sets the angle it
leaves at. The frame is solid on all four sides, so a ball that comes at its
flank or up under it is turned away too. It plays the ball's predicted
landing spot, but aims a width wide
once in a while, and every ball that gets past it puts one on the
seven-segment counter in the top right.
- **trail** — the frame glides along a curving path, steering from waypoint to
waypoint, and drops a screenshot of itself every frame-width or so. The stills
stay where they were taken and fade over a few seconds, leaving a comet tail of
frozen video behind the live one.
- **wash** — the frame parks in the middle and the colour changes instead: a
gradient creeps round the colour wheel behind the poster, taking a minute and
a half per lap, and the frame's glow travels with it. The one still animation,
which is what makes it worth having between the moving ones.
The queue page's **Animation** buttons pin the player to one of them, which is
how you look at a single animation without waiting for the cycle to reach it.
**Auto** (the default) hands selection back to the shuffle bag. Switching takes
effect immediately; while pinned, track changes leave the animation running.
**The corners and trail animations ask to share your screen.** The decks are
cross-origin YouTube iframes, so nothing on the page can read their pixels —
capturing the tab and cropping out the frame is the only way to photograph what
is playing. The prompt appears once, on the first click of **Start**; choose
*this tab*. Declining is fine: the frame still parks in its corner and still
glides, just without the screenshots. The prompt is skipped entirely on a plain-HTTP origin (i.e. when
you open the player from another machine over the network), for the same reason.
## Adding animations
`#frameMover` carries only the motion transform (see `public/player/motion.js`).
`#frameSkin` inside it is the cosmetic layer, and the two video decks sit inside
that. New effects should nest a fresh wrapper rather than sharing a transform
with the bounce loop.
An animation is `start(context) => stop()`, where `context` is
`{ element, capture }` — `capture` may be null, and is read late, so an
animation must cope with it being absent. `stop()` has to undo everything:
timers, listeners, and any DOM it added, because the next animation takes over
the same element immediately. Register it under a new name in the `ANIMATIONS`
map in `motion.js` **and** in the one in `config.js` (which supplies the label
for the queue page's button), and it joins the rotation.
Selection uses a shuffle bag (`createPicker`): every animation comes up exactly
once per pass, so they stay evenly used, and the same one never runs twice in a
row — including across the seam between two passes.