Files
party-player/server/screen.js
SiandClaude Opus 5 3f00897f0e Locate the player's window instead of assuming fullscreen
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
2026-08-13 21:56:31 +02:00

260 lines
9.6 KiB
JavaScript

// Screen capture, done by the machine instead of by the browser.
//
// The decks are cross-origin YouTube iframes, so the page cannot read their
// pixels. The browser's own answer to that is getDisplayMedia, which costs a
// picker, a permission, and a "Sharing this tab" bar parked over the projector
// all night — and on Firefox cannot share a tab at all. Since the server runs
// on the same machine as the screen it is trying to photograph, it can just
// take the screenshot itself.
//
// grim is the wlroots screencopy client (Hyprland, Sway, river). Elsewhere this
// reports unavailable and the player falls back to getDisplayMedia.
//
// The player sends its crop in CSS pixels, which are its own private unit: they
// are device pixels divided by devicePixelRatio, and that ratio moves with
// browser zoom and with fractional display scaling. The page cannot convert
// them itself — screen.width is reported in a different unit by every engine,
// and Wayland reports window.screenX as 0, so there is no offset to add. The
// compositor knows both numbers exactly, so we ask it: hyprctl gives the
// window's position and size in logical pixels, and the ratio of that to the
// viewport the page reports is the scale factor, whatever the zoom happens to
// be. That is what lets the player run in an ordinary window instead of
// insisting on fullscreen.
import { spawn } from 'node:child_process';
// A region grab is ~40ms and ~25KB at this quality; the stills are shown at
// screen size for a few seconds, so there is nothing to gain from going higher.
const QUALITY = 80;
// grim is quick, but a hung compositor must not hold a request open forever.
const TIMEOUT_MS = 2000;
// hyprctl is cheap, but it is called once per screenshot and the monitor layout
// does not change during a party.
const LAYOUT_TTL_MS = 5000;
/** True once we know grim can actually run — probed lazily, then remembered. */
let grimAvailable = null;
let layout = null;
let layoutAt = 0;
function run(command, args, { input = null } = {}) {
return new Promise((resolve) => {
let child;
try {
child = spawn(command, args);
} catch {
resolve(null);
return;
}
const chunks = [];
let settled = false;
const timer = setTimeout(() => {
settled = true;
child.kill('SIGKILL');
resolve(null);
}, TIMEOUT_MS);
child.stdout.on('data', (chunk) => chunks.push(chunk));
// Without a drain grim can block on a full stderr pipe.
child.stderr.resume();
child.on('error', () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(null);
});
child.on('close', (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(code === 0 ? Buffer.concat(chunks) : null);
});
if (input !== null) child.stdin.end(input);
});
}
/**
* The monitors, in the compositor's logical coordinate space — the same space
* grim's -g takes, and the same one CSS pixels live in while a page is
* fullscreen. Empty when hyprctl is missing, which the caller reads as "one
* screen, at the origin".
*/
async function monitors() {
const now = Date.now();
if (layout && now - layoutAt < LAYOUT_TTL_MS) return layout;
const out = await run('hyprctl', ['monitors', '-j']);
let parsed = [];
if (out) {
try {
parsed = JSON.parse(out.toString()).map((monitor) => ({
x: monitor.x,
y: monitor.y,
scale: monitor.scale,
// hyprctl reports the mode in device pixels; everything else here, grim
// included, works in logical ones.
width: Math.round(monitor.width / monitor.scale),
height: Math.round(monitor.height / monitor.scale),
}));
} catch {
parsed = [];
}
}
layout = parsed;
layoutAt = now;
return layout;
}
/**
* The player's window, in the compositor's logical coordinate space.
*
* `title` is the page's own document.title, which every browser puts at the
* front of its window title. Matched as a prefix rather than a substring so the
* queue tab — deliberately titled the other way round — cannot be mistaken for
* the player. If several tabs are somehow showing the player, the focused one
* wins, then the largest: on a projector that is the one the party can see.
*
* Null when nothing matches, which the caller reports rather than guessing at,
* since cropping against the wrong window photographs the wrong thing.
*/
async function playerWindow(title) {
const out = await run('hyprctl', ['clients', '-j']);
if (!out) return null;
let clients = [];
try {
clients = JSON.parse(out.toString());
} catch {
return null;
}
const matches = clients.filter(
(client) =>
typeof client.title === 'string' &&
client.title.startsWith(title) &&
Array.isArray(client.at) &&
Array.isArray(client.size) &&
client.size[0] > 0 &&
client.size[1] > 0 &&
// A window on another workspace is not on the glass. Photographing its
// coordinates would return whatever is actually there instead.
client.mapped !== false &&
client.hidden !== true,
);
if (matches.length === 0) return null;
// Best first: the one actually on screen, then the most recently focused
// (focusHistoryID counts up from 0 — there is no `focused` field on clients),
// then the largest, which on a projector is the one the party can see.
const rank = (client) => [
client.visible === false ? 1 : 0,
Number.isFinite(client.focusHistoryID) ? client.focusHistoryID : Number.MAX_SAFE_INTEGER,
-(client.size[0] * client.size[1]),
];
const [best] = matches.sort((a, b) => {
const [ra, rb] = [rank(a), rank(b)];
for (let i = 0; i < ra.length; i += 1) {
if (ra[i] !== rb[i]) return ra[i] - rb[i];
}
return 0;
});
return { x: best.at[0], y: best.at[1], width: best.size[0], height: best.size[1] };
}
/** Whether this machine can be screenshotted at all. */
export async function available() {
if (grimAvailable === null) {
grimAvailable = (await run('grim', ['-h'])) !== null;
}
return grimAvailable;
}
/**
* Where the page's viewport sits on screen, in logical pixels, given the
* player's window and the viewport size the page reports in CSS pixels.
*
* The viewport spans the window's full width — browsers put no chrome down the
* sides — so their ratio is the CSS-pixel scale outright. CSS pixels are square,
* so that one number does for both axes, and whatever vertical room it does not
* account for is the tab and address bars. Measuring the chrome rather than
* assuming it is zero is what lets a windowed player work; fullscreen is then
* just the case where the leftover happens to be nothing.
*/
export function viewportOrigin(window, viewportWidth, viewportHeight) {
const scale = window.width / viewportWidth;
const chrome = window.height - viewportHeight * scale;
return { x: window.x, y: window.y + chrome, scale };
}
/**
* Photograph one rectangle of the player's viewport.
*
* `rect` is in CSS pixels, relative to the viewport — straight out of
* getBoundingClientRect, with no fullscreen assumption behind it. `title` is the
* page's document.title, used to find the window (see playerWindow).
*
* Resolves to a JPEG buffer, or null if grim is unusable, the window cannot be
* found, or the rectangle falls outside the monitor.
*/
export async function capture({ x, y, width, height, viewportWidth, viewportHeight, title }) {
if (!(await available())) return null;
if (!(viewportWidth > 0 && viewportHeight > 0)) return null;
const window = await playerWindow(title);
if (!window) return null;
const origin = viewportOrigin(window, viewportWidth, viewportHeight);
// Into global logical pixels, the space grim's -g takes.
const gx = origin.x + x * origin.scale;
const gy = origin.y + y * origin.scale;
const gw = width * origin.scale;
const gh = height * origin.scale;
// Which monitor the window is on, so a player on the projector is measured
// against the projector and not the laptop panel.
const screens = await monitors();
const centreX = window.x + window.width / 2;
const centreY = window.y + window.height / 2;
const screen =
screens.find(
(m) =>
centreX >= m.x && centreX < m.x + m.width && centreY >= m.y && centreY < m.y + m.height,
) ??
screens[0] ??
{ x: 0, y: 0, scale: null, width: Infinity, height: Infinity };
// The frame can hang off an edge mid-bounce. Clip rather than refuse: grim
// fails outright on a region that leaves the output.
const left = Math.max(screen.x, Math.min(gx, screen.x + screen.width));
const top = Math.max(screen.y, Math.min(gy, screen.y + screen.height));
const right = Math.max(left, Math.min(gx + gw, screen.x + screen.width));
const bottom = Math.max(top, Math.min(gy + gh, screen.y + screen.height));
const w = Math.round(right - left);
const h = Math.round(bottom - top);
if (w < 1 || h < 1) return null;
const args = ['-t', 'jpeg', '-q', String(QUALITY)];
// grim otherwise renders at the largest scale of every monitor it can see, so
// on a mixed-DPI desk the size of a screenshot would depend on which other
// screens happen to be plugged in. Naming the scale also keeps it native:
// asking grim to resample is an order of magnitude slower than not.
// (-o would pin the scale too, but grim rejects it alongside -g.)
if (screen.scale) args.push('-s', String(screen.scale));
// left/top are already global: the viewport origin was resolved against the
// window's own position, which hyprctl reports in the same space.
args.push('-g', `${Math.round(left)},${Math.round(top)} ${w}x${h}`, '-');
return run('grim', args);
}