Make screen capture work in Firefox
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
This commit is contained in:
@@ -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
|
**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 —
|
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
|
capturing the screen and cropping out the frame is the only way to photograph
|
||||||
is playing. The prompt appears once, on the first click of **Start**; choose
|
what is playing. The prompt appears once, on the first click of **Start**, which
|
||||||
*this tab*. Declining is fine: the frame still parks in its corner and still
|
also puts the page into fullscreen.
|
||||||
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.
|
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 <kbd>F11</kbd> 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
|
## Adding animations
|
||||||
|
|
||||||
|
|||||||
+85
-13
@@ -2,44 +2,99 @@
|
|||||||
//
|
//
|
||||||
// The decks are cross-origin YouTube iframes, so nothing on the page can read
|
// 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
|
// 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
|
// screen through getDisplayMedia and cropping out the frame's rect is the only
|
||||||
// to get the actually-playing image.
|
// 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
|
* 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
|
* null if the operator declines or the API is missing — callers degrade rather
|
||||||
* than fail.
|
* 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
|
* Must be called synchronously from a user gesture (getDisplayMedia needs
|
||||||
* transient activation), so kick it off before any await and keep the promise.
|
* 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
|
// Undefined on a plain-HTTP origin, which is exactly how the README tells
|
||||||
// people to reach this page from other machines on the network.
|
// 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 {
|
try {
|
||||||
return navigator.mediaDevices
|
return navigator.mediaDevices
|
||||||
.getDisplayMedia({
|
.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 },
|
video: { frameRate: 8 },
|
||||||
audio: false,
|
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,
|
preferCurrentTab: true,
|
||||||
})
|
})
|
||||||
.then(attach)
|
.then((stream) => attach(stream, onIssue))
|
||||||
.catch(() => null);
|
.catch((error) => {
|
||||||
} catch {
|
onIssue(describe(error));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
// Some rejections arrive synchronously. Never let them escape into
|
// Some rejections arrive synchronously. Never let them escape into
|
||||||
// start(), which would abort playback before the gate even lifts.
|
// start(), which would abort playback before the gate even lifts.
|
||||||
|
onIssue(describe(error));
|
||||||
return Promise.resolve(null);
|
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();
|
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');
|
const video = document.createElement('video');
|
||||||
video.srcObject = stream;
|
video.srcObject = stream;
|
||||||
@@ -56,12 +111,29 @@ async function attach(stream) {
|
|||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
const context = canvas.getContext('2d');
|
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 {
|
return {
|
||||||
/**
|
/**
|
||||||
* Crop a viewport rect out of the live stream. `rect` is in CSS pixels.
|
* 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 }) {
|
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 (video.readyState < 2 || !video.videoWidth) return null;
|
||||||
if (width <= 0 || height <= 0) return null;
|
if (width <= 0 || height <= 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@
|
|||||||
|
|
||||||
<div id="status" hidden>reconnecting…</div>
|
<div id="status" hidden>reconnecting…</div>
|
||||||
|
|
||||||
|
<!-- Why screenshots are missing or misaligned. Set by the capture layer. -->
|
||||||
|
<div id="notice" hidden></div>
|
||||||
|
|
||||||
<script type="module" src="/player/player.js"></script>
|
<script type="module" src="/player/player.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -318,3 +318,18 @@ body {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
text-transform: uppercase;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
+29
-2
@@ -50,8 +50,16 @@ async function start() {
|
|||||||
started = true;
|
started = true;
|
||||||
|
|
||||||
// Before any await: getDisplayMedia needs the click's transient activation,
|
// Before any await: getDisplayMedia needs the click's transient activation,
|
||||||
// which is gone the moment this function yields.
|
// which is gone the moment this function yields. It goes first for the same
|
||||||
const capturing = requestCapture();
|
// 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');
|
gate.classList.add('hidden');
|
||||||
|
|
||||||
@@ -330,6 +338,25 @@ function onStatus(status) {
|
|||||||
document.getElementById('status').hidden = status === 'online';
|
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();
|
updateGate();
|
||||||
|
|
||||||
// Debug helper used when verifying the crossfade without waiting out a track.
|
// Debug helper used when verifying the crossfade without waiting out a track.
|
||||||
|
|||||||
Reference in New Issue
Block a user