diff --git a/README.md b/README.md index 7db5164..bec27c0 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,21 @@ 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 ``. 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. +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 @@ -120,10 +132,12 @@ sharing indicator, and what you choose in the picker matters: - **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. +**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 F11. 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 diff --git a/public/player/capture.js b/public/player/capture.js index 95ca53c..37e2d4a 100644 --- a/public/player/capture.js +++ b/public/player/capture.js @@ -15,10 +15,13 @@ // path removes — a picker, a sharing indicator, and on Firefox no tab capture // at all, only whole windows and screens. // -// Both need the page fullscreen, for the same reason: only then does a CSS -// pixel equal a screen pixel, and a shared window can never be mapped at all — -// it carries the tab bar and URL bar, and Wayland reports window.screenX as 0, -// so there is no offset to subtract. +// The two paths differ in what they need of the window. The fallback crops a +// stream of the whole screen, so it needs the page fullscreen: only then does a +// CSS pixel equal a screen pixel, and only then is the viewport's origin the +// screen's origin — Wayland reports window.screenX as 0, so there is no offset +// to subtract. The server path needs neither, because it asks the compositor +// where the window actually is and how big, and derives the scale and the +// browser-chrome offset from that. It works windowed, and at any zoom. // The server screenshots its own screen, which is the player's screen only when // the two are the same machine. Opened from a laptop across the network, /shot @@ -33,8 +36,10 @@ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']); let serverShots = null; if (LOCAL_HOSTS.has(location.hostname)) { - // Any rect will do; only the status code is read. - fetch('/shot?x=0&y=0&w=2&h=2&sw=0&sh=0', { method: 'GET', cache: 'no-store' }) + // Any rect will do; only the status code is read. 501 is "this machine cannot + // screenshot", which is the one answer that rules the server path out — a 422 + // here would only mean the window was not found yet, which is transient. + fetch(shotUrl({ x: 0, y: 0, width: 2, height: 2 }), { method: 'GET', cache: 'no-store' }) .then((response) => { serverShots = response.status !== 501 && response.status !== 404; }) @@ -43,6 +48,27 @@ if (LOCAL_HOSTS.has(location.hostname)) { }); } +/** + * A /shot URL for a viewport rect, in CSS pixels. The viewport size and the + * page title travel with every request: the server needs the first to size a + * CSS pixel and the second to find this page's window. + */ +function shotUrl({ x, y, width, height }, nonce = 0) { + const query = new URLSearchParams({ + x: Math.round(x), + y: Math.round(y), + w: Math.round(width), + h: Math.round(height), + vw: window.innerWidth, + vh: window.innerHeight, + title: document.title, + // The server sends no-store, but a nonce also keeps two stills taken at the + // same spot from collapsing into one request in flight. + n: String(nonce), + }); + return `/shot?${query}`; +} + /** * Ask for a screenshot source. Resolves to a `{ grab, stop }` handle, or to * null if there is none — callers degrade rather than fail. @@ -61,9 +87,15 @@ export function requestCapture({ onIssue = () => {} } = {}) { /** * True while the viewport covers the whole screen, which is the one case the - * crop maths can handle. Tested by dimensions rather than by + * *fallback's* crop maths can handle. Tested by dimensions rather than by * `document.fullscreenElement`, because F11 fullscreen is browser-level and * leaves that property null. + * + * Only the getDisplayMedia path consults this. Note that the equality is exact + * and so holds only at devicePixelRatio 1: innerWidth is device pixels divided + * by that ratio, while screen.width is not, so any browser zoom or fractional + * display scale puts the two in different units. That is a real limit of the + * fallback, and one more reason the server path is preferred. */ function fillsScreen() { return window.innerWidth === screen.width && window.innerHeight === screen.height; @@ -97,38 +129,68 @@ function trackAlignment(onIssue, initial) { // --- server screenshots ------------------------------------------------- function openServerCapture(onIssue) { - const aligned = trackAlignment(onIssue, fillsScreen()); let nonce = 0; + // One real request up front, so a server that cannot find this page's window + // says so at the start rather than leaving the animations quietly still-less. + // The elements the stills go into report nothing we can read from here. + checkServerCrop(onIssue); + + // Debounced: every check costs a screenshot on the server, and a window drag + // fires resize dozens of times. Only the settled size is worth testing. + let recheck = 0; + function onResize() { + clearTimeout(recheck); + recheck = setTimeout(() => checkServerCrop(onIssue), 500); + } + window.addEventListener('resize', onResize); + return { /** - * A URL for a fresh photograph of `rect`, in CSS pixels. Safe to hand - * straight to an : the server returns the crop itself, already sized. - * Null while the viewport does not cover the screen. + * A URL for a fresh photograph of `rect`, in CSS pixels relative to the + * viewport. Safe to hand straight to an : the server returns the crop + * itself, already sized. */ grab({ x, y, width, height }) { - if (!aligned()) return null; if (width <= 0 || height <= 0) return null; - - const query = new URLSearchParams({ - x: Math.round(x), - y: Math.round(y), - w: Math.round(width), - h: Math.round(height), - sw: screen.width, - sh: screen.height, - // The server sends no-store, but a nonce also keeps two stills taken at - // the same spot from collapsing into one request in flight. - n: String(++nonce), - }); - return `/shot?${query}`; + return shotUrl({ x, y, width, height }, ++nonce); }, - // Nothing is held open: each screenshot is one request. - stop() {}, + // No stream is held open — each screenshot is one request — but the resize + // watcher still has to go, or a stopped handle keeps probing the server. + stop() { + clearTimeout(recheck); + window.removeEventListener('resize', onResize); + }, }; } +/** + * Report only transitions, not every failure: this runs again on each resize, + * and a stuck state would otherwise repeat the same notice. + */ +let cropWorked = true; + +async function checkServerCrop(onIssue) { + let ok = false; + try { + const response = await fetch(shotUrl({ x: 0, y: 0, width: 16, height: 16 }), { + cache: 'no-store', + }); + ok = response.ok; + } catch { + ok = false; + } + + if (ok === cropWorked) return; + cropWorked = ok; + onIssue( + ok + ? 'Screenshots are working again.' + : 'Screenshots paused: the server cannot find this page’s window to photograph.', + ); +} + // --- getDisplayMedia fallback ------------------------------------------- function requestDisplayMedia(onIssue) { diff --git a/public/player/player.js b/public/player/player.js index 1c75f7c..37604ae 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -64,12 +64,11 @@ async function start() { // chosen without one, so this ordering has to hold either way. const capturing = requestCapture({ onIssue: showNotice }); - // Screenshots only line up while the viewport covers the whole screen, since - // that is what makes a CSS pixel a screen pixel. Chrome's tab capture is the - // one exception, and fullscreen does it no harm. - document.documentElement.requestFullscreen?.().catch(() => { - showNotice('Could not go fullscreen — press F11 so screenshots line up.'); - }); + // Cosmetic now rather than required: the server path locates the window + // itself, so screenshots line up windowed too. It is still what you want on a + // projector, so ask — but say nothing if it is refused, since nothing breaks. + // (The getDisplayMedia fallback does still need it; that path says so itself.) + document.documentElement.requestFullscreen?.().catch(() => {}); gate.classList.add('hidden'); diff --git a/public/queue/index.html b/public/queue/index.html index 267c4a0..bf763bc 100644 --- a/public/queue/index.html +++ b/public/queue/index.html @@ -3,7 +3,10 @@ - Party Player — Queue + + Queue — Party Player diff --git a/server/index.js b/server/index.js index f99595f..2b81ea7 100644 --- a/server/index.js +++ b/server/index.js @@ -51,14 +51,24 @@ app.get('/shot', async (req, res) => { y: Number(req.query.y), width: Number(req.query.w), height: Number(req.query.h), - screenWidth: Number(req.query.sw), - screenHeight: Number(req.query.sh), + // The viewport the page measured its rect against. The crop is in CSS + // pixels, and this is what lets the server work out what one of those is + // worth on screen — see server/screen.js. + viewportWidth: Number(req.query.vw), + viewportHeight: Number(req.query.vh), }; if (Object.values(rect).some((value) => !Number.isFinite(value))) { res.sendStatus(400); return; } + // The page's own document.title, which is how its window is picked out. + rect.title = String(req.query.title ?? ''); + if (!rect.title) { + res.sendStatus(400); + return; + } + // 501, not 404: the player probes this route to decide whether to fall back // to getDisplayMedia, and needs to tell "wrong machine" from "no route". if (!(await screen.available())) { diff --git a/server/screen.js b/server/screen.js index a6cf2c9..45af4f4 100644 --- a/server/screen.js +++ b/server/screen.js @@ -9,6 +9,17 @@ // // 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'; @@ -101,6 +112,64 @@ async function monitors() { 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) { @@ -110,31 +179,67 @@ export async function available() { } /** - * Photograph one rectangle of the screen the player is on. + * 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. * - * `rect` is in CSS pixels, which equal logical screen pixels while the page is - * fullscreen — that equality is the whole reason the caller insists on - * fullscreen. `screenWidth`/`screenHeight` name which monitor to measure - * against, so a player on a projector is not cropped against the laptop panel. - * - * Resolves to a JPEG buffer, or null if grim is unusable or the rectangle - * falls outside the screen. + * 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 async function capture({ x, y, width, height, screenWidth, screenHeight }) { - if (!(await available())) return null; +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) => m.width === screenWidth && m.height === screenHeight) ?? + 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: screenWidth, height: screenHeight }; + { 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(0, Math.min(x, screen.width)); - const top = Math.max(0, Math.min(y, screen.height)); - const right = Math.max(left, Math.min(x + width, screen.width)); - const bottom = Math.max(top, Math.min(y + height, screen.height)); + 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; @@ -146,7 +251,9 @@ export async function capture({ x, y, width, height, screenWidth, screenHeight } // 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)); - args.push('-g', `${Math.round(screen.x + left)},${Math.round(screen.y + top)} ${w}x${h}`, '-'); + // 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); }