// 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.
//
// 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 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.
fetch('/shot?x=0&y=0&w=2&h=2&sw=0&sh=0', { method: 'GET', cache: 'no-store' })
.then((response) => {
serverShots = response.status !== 501 && response.status !== 404;
})
.catch(() => {
serverShots = false;
});
}
/**
* 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
* crop maths can handle. Tested by dimensions rather than by
* `document.fullscreenElement`, because F11 fullscreen is browser-level and
* leaves that property null.
*/
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) {
const aligned = trackAlignment(onIssue, fillsScreen());
let nonce = 0;
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.
*/
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}`;
},
// Nothing is held open: each screenshot is one request.
stop() {},
};
}
// --- 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();
},
};
}