The corners and trail animations had stopped showing stills. Neither was at fault: capture.js gated every screenshot on window.innerWidth === screen.width && window.innerHeight === screen.height and that compares two different units. innerWidth is device pixels divided by devicePixelRatio; screen.width is not divided. At any zoom but 100%, or any fractional display scale, the two can never be equal — measured here at dpr 1.25, fullscreen gives 1536x864 against a reported 1920x1080. The gate stayed shut, grab() returned null, and shoot()/drop() bailed out before making a request. The only symptom was an F11 notice that stayed wrong after F11. Forcing the gate open would not have helped: the rect went out in CSS pixels and was read as logical screen pixels, so every crop would have been 1.25x too small and offset. Stop guessing from browser metrics and ask the compositor, which knows exactly. hyprctl gives the window's position and size in logical pixels; against the viewport the page reports, their ratio is the CSS-pixel scale outright, and since CSS pixels are square that one number does for both axes. The vertical room it leaves over is the tab and address bars, measured rather than assumed — which is what lets the player run in an ordinary window. Fullscreen becomes just the case where the leftover is nothing, so Start still asks for it, for the projector's sake, but says nothing when refused. The window is found by matching document.title as a prefix. The queue page was titled "Party Player — Queue", which would have matched the player's prefix and aimed the crop at the wrong window, so it is now "Queue — Party Player". Candidates are ranked on screen first, then most recently focused, then largest: hyprctl clients has no `focused` field, it has focusHistoryID. The old F11 notice is replaced by one that fires only when the server genuinely cannot find the window, debounced on resize so dragging does not spawn a burst of grim processes. Verified against the live player window: capture() returns bytes identical to a grim -g of the independently predicted region, the same physical region described at 100% and 125% zoom resolves to identical output, and the route answers 422 for an unknown window and 400 for a missing title or viewport. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015zyhxe2w7eEMXvz8wekCyc
167 lines
8.8 KiB
Markdown
167 lines
8.8 KiB
Markdown
# 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 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.
|
||
|
||
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.
|