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 @@