Screenshot from the server instead of the browser

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
This commit is contained in:
simon
2026-08-10 22:49:36 +02:00
co-authored by Claude Opus 5
parent cc6ea58c7c
commit 0683acd69e
5 changed files with 351 additions and 68 deletions
+25 -13
View File
@@ -98,27 +98,39 @@ 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
**The corners and trail animations photograph the frame.** The decks are
cross-origin YouTube iframes, so nothing on the page can read their pixels —
capturing the screen and cropping out the frame is the only way to photograph
what is playing. The prompt appears once, on the first click of **Start**, which
also puts the page into fullscreen.
a screenshot is the only way to capture what is playing.
The crop assumes the captured surface *is* the viewport, so what you pick in the
picker matters:
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` 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**. Fullscreen is what makes it line up, so if you
leave fullscreen, press <kbd>F11</kbd> to get screenshots back.
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.
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. Whenever screenshots are off or misaligned, the player says why in
the bottom-left corner and logs it to the console.
**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 <kbd>F11</kbd>. 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