// Screen capture, used to photograph the video frame. // // The decks are cross-origin YouTube iframes, so nothing on the page can read // their pixels — canvas taints, html2canvas sees an empty box. Capturing the // screen through getDisplayMedia and cropping out the frame's rect is the only // way to get the actually-playing image. // // The crop assumes the captured surface *is* the viewport, so stream pixels map // to CSS pixels by a single scale factor. Chrome can guarantee that with tab // capture. Firefox has no tab capture at all — its picker offers only windows // and screens — so there the operator shares the whole screen and the page goes // fullscreen to match it. A shared window can never be mapped: it carries the // tab bar and URL bar, and Wayland reports window.screenX/screenY as 0, so // there is no offset to subtract. /** * Ask for the screen-share stream. Resolves to a `{ grab, stop }` handle, or to * null if the operator declines or the API is missing — 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 (getDisplayMedia needs * transient activation), so kick it off before any await and keep the promise. */ export function requestCapture({ 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.`; } /** * True while the viewport covers the whole captured surface, which is the one * case the crop maths can handle. * * `browser` is Chrome's tab capture: the stream is the viewport by definition. * For a screen it holds only while the page is fullscreen — tested by * dimensions rather than `document.fullscreenElement`, because F11 fullscreen * is browser-level and leaves that property null. */ function mapsToViewport(surface) { if (surface === 'browser') return true; if (surface === 'window') return false; return window.innerWidth === screen.width && window.innerHeight === screen.height; } async function attach(stream, onIssue) { const [track] = stream.getVideoTracks(); if (!track) { onIssue('The shared stream carried no video — running without screenshots.'); return null; } const surface = track.getSettings?.().displaySurface ?? null; if (surface === 'window') { onIssue('Sharing a single window cannot be cropped — restart and share the whole screen instead.'); } else if (!mapsToViewport(surface)) { onIssue('Screenshots need the page fullscreen to line up with the shared screen — press F11.'); } 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'); // The operator can leave fullscreen mid-party, so the mapping is rechecked on // every grab. Only the transitions are reported: grab() runs per animation // frame, and a stuck state would otherwise repeat the notice forever. let mapped = mapsToViewport(surface); 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 }) { const nowMapped = mapsToViewport(surface); if (nowMapped !== mapped) { mapped = nowMapped; onIssue( mapped ? 'Screenshots are lined up again.' : 'Screenshots paused: the page no longer fills the shared screen — press F11.', ); } if (!mapped) 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(); }, }; }