getDisplayMedia costs a picker, a permission, and a "Sharing this tab" bar parked over the projector all night. None of it can be suppressed from the page — the indicator is a security surface with no API, and the Chromium switches that hide it were no help here. The server runs on the machine the screen is plugged into, so it can take the screenshot itself. server/screen.js drives grim for one region and /shot returns the finished crop, which means grab() hands back a URL for an <img> rather than a data URL off a canvas: same synchronous contract, so the animations are untouched, and no tainting to work around either. Coordinates line up because a fullscreen page's CSS pixels are the compositor's logical pixels, which is the space grim's -g takes. The monitor layout comes from hyprctl, so a projector is measured against itself rather than the laptop panel, and -s pins the scale to that monitor: it keeps a mixed-DPI desk deterministic, and it keeps grim native, where resampling costs 440ms against 35ms. Needs a wlroots compositor and grim on PATH; without them the route reports 501 and the player falls back to getDisplayMedia, probed at load because that call cannot be reached through an await without losing transient activation. Also skipped when the page is not on localhost, where the server would be photographing its own screen rather than the viewer's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
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) and the poster behind it (see 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
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.jsonon 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 exceptPORTis mirrored to the browser by the generated/config.jsroute inserver/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 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 reads the monitor layout from hyprctl so the right screen
is measured when a projector is plugged in.
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.
Either way the page has to be fullscreen, which is why Start requests it: only then does a CSS pixel line up with a screen pixel. Leave fullscreen and the screenshots pause until you press F11. The exception is Chrome's tab capture, which is the viewport by definition and lines up in a window too.
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.