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