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
This commit is contained in:
Si
2026-08-13 21:56:31 +02:00
co-authored by Claude Opus 5
parent 049a269885
commit 3f00897f0e
6 changed files with 255 additions and 60 deletions
+89 -27
View File
@@ -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 <img> 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 <img>: 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 <img>: 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) {