Firefox has no tab capture, so preferCurrentTab is silently dropped and the operator can only share a window or a screen. Neither maps onto the viewport the way the crop assumes, so screenshots came out offset by the browser chrome — and every failure was swallowed by a bare catch, leaving no way to tell why. Check the shared surface and only crop when the viewport covers it: exact for Chrome's tab capture, true for a screen while the page is fullscreen. Test that by dimensions rather than document.fullscreenElement, which F11 leaves null. Start now requests fullscreen, after getDisplayMedia so the click's transient activation goes to the fragile call. Reasons surface in a corner notice and the console, and the check reruns per grab so leaving fullscreen says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9v2Xoa6eoeGYpK6gp95k9
167 lines
6.3 KiB
JavaScript
167 lines
6.3 KiB
JavaScript
// 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();
|
|
},
|
|
};
|
|
}
|