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
310 lines
11 KiB
JavaScript
310 lines
11 KiB
JavaScript
// Screenshots of the video frame, from one of two backends.
|
||
//
|
||
// The decks are cross-origin YouTube iframes, so nothing on the page can read
|
||
// their pixels — canvas taints, html2canvas sees an empty box. Photographing
|
||
// the screen is the only way to get the actually-playing image.
|
||
//
|
||
// Preferred: the server takes the screenshot (see server/screen.js). It runs on
|
||
// the machine the projector is plugged into, so it can grab the region itself
|
||
// with no picker, no permission, and no "Sharing this tab" bar sitting over the
|
||
// party. The response is the finished crop, so `grab` hands back a URL and an
|
||
// <img> does the rest.
|
||
//
|
||
// Fallback: getDisplayMedia, for machines the server cannot screenshot. That
|
||
// path crops a live stream on a canvas, and carries all the caveats the server
|
||
// path removes — a picker, a sharing indicator, and on Firefox no tab capture
|
||
// at all, only whole windows and screens.
|
||
//
|
||
// 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
|
||
// would photograph the projector and crop the laptop's coordinates out of it.
|
||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
||
|
||
/**
|
||
* Whether the server can screenshot for us. Probed at load rather than at the
|
||
* click, because getDisplayMedia needs the click's transient activation and
|
||
* cannot be reached through an await.
|
||
*/
|
||
let serverShots = null;
|
||
|
||
if (LOCAL_HOSTS.has(location.hostname)) {
|
||
// 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;
|
||
})
|
||
.catch(() => {
|
||
serverShots = false;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*
|
||
* `onIssue` receives a human-readable reason whenever screenshots are
|
||
* unavailable or stop lining up; without it these failures are invisible.
|
||
*
|
||
* Must be called synchronously from a user gesture: the fallback's
|
||
* getDisplayMedia needs transient activation, so kick this off before any await
|
||
* and keep the promise.
|
||
*/
|
||
export function requestCapture({ onIssue = () => {} } = {}) {
|
||
if (serverShots) return Promise.resolve(openServerCapture(onIssue));
|
||
return requestDisplayMedia(onIssue);
|
||
}
|
||
|
||
/**
|
||
* True while the viewport covers the whole screen, which is the one case the
|
||
* *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;
|
||
}
|
||
|
||
/**
|
||
* Watch the viewport-covers-screen condition and report only its transitions:
|
||
* grab() runs as often as once per animation frame, and a stuck state would
|
||
* otherwise repeat the same notice forever.
|
||
*/
|
||
function trackAlignment(onIssue, initial) {
|
||
let mapped = initial;
|
||
if (!mapped) {
|
||
onIssue('Screenshots need the page fullscreen to line up with the screen — press F11.');
|
||
}
|
||
|
||
return function aligned() {
|
||
const now = fillsScreen();
|
||
if (now !== mapped) {
|
||
mapped = now;
|
||
onIssue(
|
||
mapped
|
||
? 'Screenshots are lined up again.'
|
||
: 'Screenshots paused: the page no longer fills the screen — press F11.',
|
||
);
|
||
}
|
||
return mapped;
|
||
};
|
||
}
|
||
|
||
// --- server screenshots -------------------------------------------------
|
||
|
||
function openServerCapture(onIssue) {
|
||
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 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 (width <= 0 || height <= 0) return null;
|
||
return shotUrl({ x, y, width, height }, ++nonce);
|
||
},
|
||
|
||
// 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) {
|
||
// Undefined on a plain-HTTP origin, which is exactly how the README tells
|
||
// people to reach this page from other machines on the network.
|
||
if (!navigator.mediaDevices?.getDisplayMedia) {
|
||
onIssue('Screen capture needs a secure origin (localhost or HTTPS) — running without screenshots.');
|
||
return Promise.resolve(null);
|
||
}
|
||
|
||
try {
|
||
return navigator.mediaDevices
|
||
.getDisplayMedia({
|
||
video: { frameRate: 8 },
|
||
audio: false,
|
||
// Chrome-only, and only a hint: it preselects this tab, whose stream
|
||
// matches the viewport exactly. Firefox drops the unknown member
|
||
// silently and offers windows and screens instead — hence the surface
|
||
// check in attach().
|
||
preferCurrentTab: true,
|
||
})
|
||
.then((stream) => attach(stream, onIssue))
|
||
.catch((error) => {
|
||
onIssue(describe(error));
|
||
return null;
|
||
});
|
||
} catch (error) {
|
||
// Some rejections arrive synchronously. Never let them escape into
|
||
// start(), which would abort playback before the gate even lifts.
|
||
onIssue(describe(error));
|
||
return Promise.resolve(null);
|
||
}
|
||
}
|
||
|
||
function describe(error) {
|
||
const name = error?.name ?? 'Error';
|
||
if (name === 'NotAllowedError') {
|
||
return 'Screen sharing declined — the animations run without screenshots.';
|
||
}
|
||
if (name === 'NotFoundError' || name === 'AbortError') {
|
||
return 'No screen was shared — the animations run without screenshots.';
|
||
}
|
||
return `Screen capture failed (${name}: ${error?.message || 'no detail'}) — running without screenshots.`;
|
||
}
|
||
|
||
async function attach(stream, onIssue) {
|
||
const [track] = stream.getVideoTracks();
|
||
if (!track) {
|
||
onIssue('The shared stream carried no video — running without screenshots.');
|
||
return null;
|
||
}
|
||
|
||
// 'browser' is Chrome's tab capture: the stream is the viewport by
|
||
// definition, so it lines up whether or not the page is fullscreen.
|
||
const surface = track.getSettings?.().displaySurface ?? null;
|
||
if (surface === 'window') {
|
||
onIssue('Sharing a single window cannot be cropped — restart and share the whole screen instead.');
|
||
}
|
||
const aligned =
|
||
surface === 'browser'
|
||
? () => true
|
||
: trackAlignment(onIssue, surface !== 'window' && fillsScreen());
|
||
|
||
const video = document.createElement('video');
|
||
video.srcObject = stream;
|
||
video.muted = true;
|
||
video.playsInline = true;
|
||
// Must be in the document, like #probe: a detached video is never composited
|
||
// and the browser throttles its frames hard, which would leave screenshots
|
||
// showing a moment several seconds stale. 1x1 and transparent, so it costs
|
||
// nothing and cannot show up in its own crop.
|
||
video.id = 'captureSink';
|
||
document.body.appendChild(video);
|
||
await video.play().catch(() => {});
|
||
|
||
const canvas = document.createElement('canvas');
|
||
const context = canvas.getContext('2d');
|
||
|
||
return {
|
||
/**
|
||
* Crop a viewport rect out of the live stream. `rect` is in CSS pixels.
|
||
* Returns a data URL, or null while the stream has yet to deliver a frame
|
||
* or while the viewport cannot be located inside the captured surface.
|
||
*/
|
||
grab({ x, y, width, height }) {
|
||
if (!aligned()) return null;
|
||
if (video.readyState < 2 || !video.videoWidth) return null;
|
||
if (width <= 0 || height <= 0) return null;
|
||
|
||
const scaleX = video.videoWidth / window.innerWidth;
|
||
const scaleY = video.videoHeight / window.innerHeight;
|
||
|
||
canvas.width = Math.round(width * scaleX);
|
||
canvas.height = Math.round(height * scaleY);
|
||
context.drawImage(
|
||
video,
|
||
x * scaleX,
|
||
y * scaleY,
|
||
width * scaleX,
|
||
height * scaleY,
|
||
0,
|
||
0,
|
||
canvas.width,
|
||
canvas.height,
|
||
);
|
||
|
||
return canvas.toDataURL('image/jpeg', 0.85);
|
||
},
|
||
|
||
stop() {
|
||
for (const t of stream.getTracks()) t.stop();
|
||
video.srcObject = null;
|
||
video.remove();
|
||
},
|
||
};
|
||
}
|