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 @@ + + + diff --git a/public/player/player.css b/public/player/player.css index 608140f..097f8be 100644 --- a/public/player/player.css +++ b/public/player/player.css @@ -318,3 +318,18 @@ body { letter-spacing: 0.08em; text-transform: uppercase; } + +/* Bottom left, opposite #status, so the two never overlap. */ +#notice { + position: fixed; + bottom: 12px; + left: 16px; + z-index: 20; + max-width: min(46ch, 60vw); + padding: 8px 12px; + border-radius: 6px; + background: rgb(0 0 0 / 0.55); + color: var(--accent); + font-size: 0.8rem; + line-height: 1.35; +} diff --git a/public/player/player.js b/public/player/player.js index cf7cc9a..c00fa68 100644 --- a/public/player/player.js +++ b/public/player/player.js @@ -50,8 +50,16 @@ async function start() { started = true; // Before any await: getDisplayMedia needs the click's transient activation, - // which is gone the moment this function yields. - const capturing = requestCapture(); + // which is gone the moment this function yields. It goes first for the same + // reason — it is the call that must not lose the activation to another API. + const capturing = requestCapture({ onIssue: showNotice }); + + // Screenshots only line up while the viewport covers the whole captured + // surface. Firefox cannot share a bare tab, so the operator shares the screen + // and this makes the page match it. Harmless on the tab-capture path. + document.documentElement.requestFullscreen?.().catch(() => { + showNotice('Could not go fullscreen — press F11 so screenshots line up.'); + }); gate.classList.add('hidden'); @@ -330,6 +338,25 @@ function onStatus(status) { document.getElementById('status').hidden = status === 'online'; } +// --- notices ------------------------------------------------------------ + +// Capture problems used to be swallowed silently, which left a broken +// screenshot and no way to tell why. They are advisory, never fatal, so the +// notice fades itself out rather than sitting over the party all night. +const NOTICE_MS = 12000; +let noticeTimer = null; + +function showNotice(message) { + console.warn('[player]', message); + const notice = document.getElementById('notice'); + notice.textContent = message; + notice.hidden = false; + clearTimeout(noticeTimer); + noticeTimer = setTimeout(() => { + notice.hidden = true; + }, NOTICE_MS); +} + updateGate(); // Debug helper used when verifying the crossfade without waiting out a track.