Capture Handle and Captured Surface Control
Screen capturing is not (yet) supported on your device
Captured Surface Control is not (yet) supported on your device
Capture Handle is not (yet) supported on your device
Capture Handle and Captured Surface Control enable screen capturing web apps to remotely control captured web apps.
The capturing web app can send arbitrary commands to the capturing web app, scroll it and zoom it in and out.
A great use-case for this is a screen capture web
app that can remotely control a presentation inside the web app that is captured.
The user of the capturing web app then doesn't need to switch between the capturing and captured app anymore to
control that presentation.
The captured app can opt-in through a call to navigator.mediaDevices.setCaptureHandleConfig(config)
.
The config
parameter has a handle
property containing a string that uniquely identifies
the captured app.
The capturing app retrieves the handle by calling getCaptureHandle
on the VideoTrack
of
the screen capturing MediaStream
. It then uses that handle in messages it sends to the captured app
through BroadcastChannel
. These messages instruct the captured app to go to the previous or next
image in the gallery.
Demo
Click the button "Open page" below to open a page containing an image gallery. Then capture the screen of that
page by clicking the button "Share screen".
After that, you can go back and forth between the images in the gallery from this page by clicking the "Previous"
and "Next" buttons, scroll the captured page and zoom it in and out. No need to switch between this app and the
captured app anymore!
zoom_out
zoom_in
// capturing side
let controller;
// CaptureController keeps the focus on the capturing web app
if ('CaptureController' in window && 'setFocusBehavior' in CaptureController.prototype) {
controller = new CaptureController();
controller.setFocusBehavior('no-focus-change');
}
const stream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'browser',
},
audio: true,
surfaceSwitching: 'exclude',
selfBrowserSurface: 'exclude',
preferCurrentTab: false,
systemAudio: 'include',
monitorTypeSurfaces: "exclude",
...(controller && {controller})
});
const [videoTrack] = stream.getVideoTracks();
let captureHandle = videoTrack.getCaptureHandle();
if (captureHandle) {
previousButton.disabled = false;
nextButton.disabled = false;
}
videoTrack.addEventListener('capturehandlechange', (e) => {
captureHandle = e.target.getCaptureHandle();
});
const broadcastChannel = new BroadcastChannel("capture-handle");
previousButton.addEventListener('click', () => {
broadcastChannel.postMessage({
handle: captureHandle.handle,
command: 'previous',
});
});
nextButton.addEventListener('click', () => {
broadcastChannel.postMessage({
handle: captureHandle.handle,
command: 'next',
});
});
// captured side
const config = {
handle: crypto.randomUUID(),
exposeOrigin: true,
permittedOrigins: ['*'],
};
navigator.mediaDevices.setCaptureHandleConfig(config);
const gallery = document.querySelector('image-gallery');
const broadcastChannel = new BroadcastChannel("capture-handle");
broadcastChannel.addEventListener('message', ({data}) => {
const {handle, command} = data;
// only accept commands if the handle matches
if(handle === config.handle) {
switch(command) {
case 'previous':
gallery.previous();
break;
case 'next':
gallery.next();
break;
}
}
});
// trigger permission prompt for Captured Surface Control
enableScrollingButton.onclick = (e) => {
captureController.sendWheel({});
}
// get available zoom levels
const zoomLevels = CaptureController.getSupportedZoomLevels();
// zoom in
zoomInButton.addEventListener('click', async () => {
const index = zoomLevels.indexOf(captureController.getZoomLevel());
const newZoomLevel = zoomLevels[Math.min(index + 1, zoomLevels.length - 1)];
try {
await captureController.setZoomLevel(newZoomLevel);
}
catch(err) {
console.log('zoom in error', err);
}
});
// zoom out
zoomOutButton.addEventListener('click', async () => {
const index = zoomLevels.indexOf(captureController.getZoomLevel());
const newZoomLevel = zoomLevels[Math.max(index - 1, 0)];
try {
await captureController.setZoomLevel(newZoomLevel);
}
catch(err) {
console.log('zoom out error', err);
}
});
// scroll captured side by scrolling the video track
preview.onwheel = async (e) => {
const {offsetX, offsetY, deltaX, deltaY} = e;
const [x, y] = translateCoordinates(offsetX, offsetY);
const [wheelDeltaX, wheelDeltaY] = [-deltaX, -deltaY];
try {
await captureController.sendWheel({ x, y, wheelDeltaX, wheelDeltaY });
}
catch (error) {
console.log(error);
}
};
// translate coordinates between preview and captured side
function translateCoordinates(offsetX, offsetY) {
const previewDimensions = preview.getBoundingClientRect();
const trackSettings = preview.srcObject.getVideoTracks()[0].getSettings();
const x = (trackSettings.width * offsetX) / previewDimensions.width;
const y = (trackSettings.height * offsetY) / previewDimensions.height;
return [Math.floor(x), Math.floor(y)];
}