Files
party-player/README.md
T
SiandClaude Opus 5 176ca6868f Close the three seams in the crossfade
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
2026-08-14 13:40:36 +02:00

215 lines
12 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,
animation and animation speed.
## 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, the low-res speed factor, 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.
## Crossfades
Two decks, one video each, and a track change is an equal-power fade from the
one to the other. Three details are what keep it from being audible as a seam:
- **The next track is prerolled.** `PREROLL_SECONDS` before the fade is due, the
idle deck loads it, plays until YouTube reports it running, then seeks back to
0 and pauses. A deck told to play at the moment the fade opens would spend its
first second or two fetching, and the fade — which runs on wall-clock time —
would ramp the outgoing track down over that silence. The fade still will not
open until the incoming deck is genuinely playing, so a preroll that did not
take costs a moment of the outgoing track rather than a gap.
- **The fade lands before the end, not on it.** It runs for what is actually
left of the outgoing track minus `END_GUARD_SECONDS`, rather than for a fixed
`OVERLAP_SECONDS` — the tick that triggers it is already a little late, and
`getDuration()` reports a length that runs slightly past the last of the
audio. A fade that outlasts its own track ends in a clipped ending and a flash
of YouTube's end screen, which shows the video's first frame and reads as the
track restarting.
- **The player owns the hand-over until the server confirms it.** The queue
pointer only moves once the server has processed `trackEnded`, so for one
round trip the state still names the track that just finished. The player
advances its own pointer at the same moment it sends, and reconciles when the
broadcast arrives; without that, the jump check reads the stale pointer as a
manual jump and cuts straight back to the track that just ended.
## 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.
### Speed
Every speed in `config.js` is in pixels per second, so the same numbers cross 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 queue page's **Speed** switch fixes that: **Low-res
screen** multiplies every animation clock by `LOW_RES_SPEED` (0.65, the
1280/1920 ratio), which slows the drift, the glides, the pong rally, the drag
beats, the wash lap, the screenshot intervals and the fades together.
It is one factor read per frame rather than a set of slower constants, so
flipping the switch takes effect on the next frame — no animation restarts, and
one part-way through a beat keeps its place. The audio crossfade is deliberately
left out: it is tied to the track that is ending. The setting is saved with the
queue, since it describes the screen the player is on.
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 photograph the frame.** The decks are
cross-origin YouTube iframes, so nothing on the page can read their pixels —
a screenshot is the only way to capture what is playing.
The server takes it. It runs on the same machine as the screen, so it can grab
the region itself with [`grim`](https://sr.ht/~emersion/grim) and hand back the
finished crop, which the player drops straight into an `<img>`. No picker, no
permission prompt, and no "Sharing this tab" bar parked over the party. This
needs a wlroots compositor — Hyprland, Sway, river — and `grim` on `PATH`.
`server/screen.js` also asks `hyprctl` where the player's window is and how big.
That is what makes the crop reliable: the page measures in CSS pixels, which are
device pixels divided by `devicePixelRatio`, and that ratio moves with browser
zoom and fractional display scaling. Comparing the window's real size against the
viewport the page reports gives the scale outright, and the vertical room left
over is the tab and address bars. **So the server path needs no fullscreen and no
particular zoom** — it works in an ordinary window. **Start** still asks for
fullscreen because that is what you want on a projector, but nothing breaks if
you refuse.
The window is found by matching the page's `document.title` as a prefix, so the
player must be the **active tab** in its window — which it is when it is the
thing on screen. If the server cannot find it, the player says so in the corner.
Without `grim`, the player falls back to `getDisplayMedia` and asks to share
your screen on the first click of **Start**. That path costs a picker and a
sharing indicator, and what you choose in the picker matters:
- **Chrome/Chromium** preselects *This tab* — take it, and the mapping is exact.
- **Firefox** has no tab capture; the picker offers only windows and screens.
Choose the **entire screen**.
- **A single window** cannot be used: it includes the tab and URL bars, and
Wayland gives the page no window coordinates to subtract.
**The fallback needs the page fullscreen**, which is the one case it can map: a
CSS pixel lines up with a screen pixel only there, and only at 100% zoom, since
`innerWidth` scales with zoom and `screen.width` does not. Leave fullscreen and
the screenshots pause until you press <kbd>F11</kbd>. The exception is Chrome's
tab capture, which is the viewport by definition and lines up in a window too.
The server path has none of these limits.
Going without is fine: the frame still parks in its corner and still glides,
just unphotographed. That is what happens when you open the player from another
machine over the network — the server would be screenshotting its own screen
rather than yours, so the player does not ask it to, and a plain-HTTP origin has
no `getDisplayMedia` either. Whenever screenshots are off or misaligned, the
player says why in the bottom-left corner and logs it to the console.
## 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.
Anything time-driven should take its seconds from `tickSeconds(now, last)`
rather than from the raw timestamps, and any timer should divide its interval by
`speed`. That is what puts a new animation under the **Speed** switch — and the
clamp inside it is also what stops a hidden tab from teleporting the frame when
it comes back.
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.