// 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
//
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
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
: 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();
},
};
}