diff --git a/README.md b/README.md index 7db1e43..355828e 100644 --- a/README.md +++ b/README.md @@ -100,11 +100,25 @@ effect immediately; while pinned, track changes leave the animation running. **The corners and trail animations ask to share your screen.** The decks are cross-origin YouTube iframes, so nothing on the page can read their pixels — -capturing the tab and cropping out the frame is the only way to photograph what -is playing. The prompt appears once, on the first click of **Start**; choose -*this tab*. Declining is fine: the frame still parks in its corner and still -glides, just without the screenshots. The prompt is skipped entirely on a plain-HTTP origin (i.e. when -you open the player from another machine over the network), for the same reason. +capturing the screen and cropping out the frame is the only way to photograph +what is playing. The prompt appears once, on the first click of **Start**, which +also puts the page into fullscreen. + +The crop assumes the captured surface *is* the viewport, so what you pick in the +picker matters: + +- **Chrome/Chromium** preselects *This tab* — take it, and the mapping is exact. +- **Firefox** has no tab capture; the picker offers only windows and screens. + Choose the **entire screen**. Fullscreen is what makes it line up, so if you + leave fullscreen, press F11 to get screenshots back. +- **A single window** cannot be used: it includes the tab and URL bars, and + Wayland gives the page no window coordinates to subtract. + +Declining is fine: the frame still parks in its corner and still glides, just +without the screenshots. The prompt is skipped entirely on a plain-HTTP origin +(i.e. when you open the player from another machine over the network), for the +same reason. Whenever screenshots are off or misaligned, the player says why in +the bottom-left corner and logs it to the console. ## Adding animations diff --git a/public/player/capture.js b/public/player/capture.js index 004a192..bf844b0 100644 --- a/public/player/capture.js +++ b/public/player/capture.js @@ -2,44 +2,99 @@ // // 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 -// tab through getDisplayMedia and cropping out the frame's rect is the only way -// to get the actually-playing image. +// 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() { +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) return Promise.resolve(null); + 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({ - // preferCurrentTab keeps the stream to this tab's viewport, so stream - // pixels map to CSS pixels by one scale factor. Picking a whole screen - // instead would put browser chrome in the frame and skew every crop. 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(attach) - .catch(() => null); - } catch { + .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); } } -async function attach(stream) { +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) return null; + 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; @@ -56,12 +111,29 @@ async function attach(stream) { 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. + * 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; diff --git a/public/player/index.html b/public/player/index.html index 5276511..0cb8afb 100644 --- a/public/player/index.html +++ b/public/player/index.html @@ -35,6 +35,9 @@