Rolar e aplicar zoom a uma guia capturada

François Beaufort
François Beaufort

O compartilhamento de guias, janelas e telas já é possível na plataforma da Web com a API Screen Capture. Quando um app da Web chama getDisplayMedia(), o Chrome solicita que o usuário compartilhe uma guia, janela ou tela com o app como um vídeo MediaStreamTrack.

Muitos apps da Web que usam o getDisplayMedia() mostram ao usuário uma prévia em vídeo da plataforma capturada. Por exemplo, os apps de videoconferência geralmente transmitem esse vídeo para usuários remotos e também o renderizam para um HTMLVideoElement local, para que o usuário local visse constantemente o que está compartilhando.

Esta documentação apresenta a nova API Captured Surface Control no Chrome. Ela permite que seu app da Web role uma guia capturada, além de ler e gravar o nível de zoom de uma guia capturada.

Um usuário rola e aplica zoom em uma guia capturada (demonstração).

Por que usar o Captured Surface Control?

Todos os apps de videoconferência têm a mesma desvantagem: se o usuário quiser interagir com uma guia ou janela capturada, ele precisará alternar para essa plataforma, removendo-o do app de videoconferência. Isso apresenta alguns desafios:

  • O usuário não verá o app capturado e os vídeos de usuários remotos ao mesmo tempo, a menos que use o modo Picture-in-picture ou janelas lado a lado separadas para as guias de videoconferência e compartilhada. Em uma tela menor, isso pode ser difícil.
  • O usuário fica sobrecarregado pela necessidade de alternar entre o aplicativo de videoconferência e a superfície capturada.
  • O usuário perde o acesso aos controles expostos pelo app de videoconferência enquanto está longe dele. Por exemplo, um app de chat incorporado, reações com emojis, notificações sobre os usuários pedindo para participar da chamada, controles multimídia e de layout e outros recursos úteis de videoconferência.
  • O apresentador não pode delegar o controle a participantes remotos. Isso resulta em um cenário muito familiar em que os usuários remotos pedem para o apresentador mudar o slide, rolar um pouco para cima e para baixo ou ajustar o nível de zoom.

A API Captured Surface Control resolve esses problemas.

Como usar o Captured Surface Control?

O uso do Captured Surface Control requer algumas etapas, como capturar explicitamente uma guia do navegador e receber permissão do usuário antes de rolar e aplicar zoom à guia capturada.

Capturar uma guia do navegador

Comece pedindo que o usuário escolha uma plataforma para compartilhar usando getDisplayMedia() e, no processo, associe um objeto CaptureController à sessão de captura. Em breve, vamos usar esse objeto para controlar a superfície capturada.

const controller = new CaptureController();
const stream = await navigator.mediaDevices.getDisplayMedia({ controller });

Em seguida, produza uma visualização local da superfície capturada na forma de um elemento <video>:

const previewTile = document.querySelector('video');
previewTile.srcObject = stream;

Se o usuário decidir compartilhar uma janela ou tela, isso está fora do escopo por enquanto, mas se ele optar por compartilhar uma guia, poderemos prosseguir.

const [track] = stream.getVideoTracks();

if (track.getSettings().displaySurface !== 'browser') {
  // Bail out early if the user didn't pick a tab.
  return;
}

Solicitação de permissão

A primeira invocação de sendWheel() ou setZoomLevel() em um determinado objeto CaptureController produz uma solicitação de permissão. Se o usuário conceder permissão, outras invocações desses métodos nesse objeto CaptureController serão permitidas. Se o usuário negar a permissão, a promessa retornada é rejeitada.

Os objetos CaptureController são associados exclusivamente a uma captura-sessão específica, não podem ser associados a outra sessão de captura e não sobrevivem à navegação na página em que foram definidos. No entanto, as sessões de captura sobrevivem à navegação da página capturada.

Um gesto do usuário é necessário para mostrar uma solicitação de permissão. Apenas chamadas sendWheel() e setZoomLevel() exigem um gesto do usuário e apenas se a solicitação precisar ser mostrada. Se o usuário clicar em um botão para aumentar ou diminuir o zoom no app da Web, esse gesto já terá sido concluído. No entanto, se o aplicativo quiser oferecer o controle de rolagem primeiro, os desenvolvedores precisarão ter em mente que a rolagem não constitui um gesto do usuário. Uma possibilidade é primeiro oferecer ao usuário um botão "começar a rolar", conforme o exemplo a seguir:

const startScrollingButton = document.querySelector('button');

startScrollingButton.addEventListener('click', async () => {
  try {
    const noOpWheelAction = {};

    await controller.sendWheel(noOpWheelAction);
    // The user approved the permission prompt.
    // You can now scroll and zoom the captured tab as shown later in the article.
  } catch (error) {
    return; // Permission denied. Bail.
  }
});

Rolagem

Usando sendWheel(), um app de captura pode fornecer eventos de roda de magnitude escolhida em relação às coordenadas de sua escolha na janela de visualização de uma guia. Não é possível distinguir o evento do app capturado da interação direta do usuário.

Supondo que o app de captura use um elemento <video> com o nome "previewTile", o código a seguir mostra como redirecionar eventos de roda de envio para a guia capturada:

const previewTile = document.querySelector('video');

previewTile.addEventListener('wheel', async (event) => {
  // Translate the offsets into coordinates which sendWheel() can understand.
  // The implementation of this translation is further explained below.
  const [x, y] = translateCoordinates(event.offsetX, event.offsetY);
  const [wheelDeltaX, wheelDeltaY] = [-event.deltaX, -event.deltaY];

  try {
    // Relay the user's action to the captured tab.
    await controller.sendWheel({ x, y, wheelDeltaX, wheelDeltaY });
  } catch (error) {
    // Inspect the error.
    // ...
  }
});

O método sendWheel() usa um dicionário com dois conjuntos de valores:

  • x e y: as coordenadas em que o evento de roda será entregue.
  • wheelDeltaX e wheelDeltaY: as magnitudes das rolagens, em pixels, para rolagens horizontais e verticais, respectivamente. Esses valores são invertidos em comparação com o evento de roda original.

Uma possível implementação de translateCoordinates() é:

function translateCoordinates(offsetX, offsetY) {
  const previewDimensions = previewTile.getBoundingClientRect();
  const trackSettings = previewTile.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)];
}

Observe que existem três tamanhos diferentes em jogo no código anterior:

  • O tamanho do elemento <video>.
  • O tamanho dos frames capturados (representado aqui como trackSettings.width e trackSettings.height).
  • O tamanho da guia.

O tamanho do elemento <video> está totalmente dentro do domínio do app de captura e é desconhecido para o navegador. O tamanho da guia está dentro do domínio do navegador e é desconhecido para o aplicativo da Web.

O app da Web usa translateCoordinates() para converter os deslocamentos relativos ao elemento <video> em coordenadas no próprio espaço de coordenadas da faixa de vídeo. O navegador também converterá o tamanho dos frames capturados e o da guia, exibindo o evento de rolagem em um deslocamento correspondente à expectativa do aplicativo da web.

A promessa retornada por sendWheel() pode ser rejeitada nos seguintes casos:

  • Se a sessão de captura ainda não foi iniciada ou já foi interrompida, incluindo a interrupção assíncrona enquanto a ação sendWheel() é processada pelo navegador.
  • Se o usuário não concedeu permissão ao app para usar sendWheel().
  • Se o app de captura tentar entregar um evento de rolagem em coordenadas que estão fora de [trackSettings.width, trackSettings.height]. Observe que esses valores podem mudar de forma assíncrona, por isso é uma boa ideia detectar e ignorar o erro. Como os 0, 0 normalmente não estão fora dos limites, é seguro usá-los para solicitar a permissão do usuário.

Zoom

A interação com o nível de zoom da guia capturada é feita nas seguintes superfícies do CaptureController:

  • getSupportedZoomLevels() retorna uma lista de níveis de zoom aceitos pelo navegador, representada como porcentagens do "nível de zoom padrão", que é definido como 100%. Essa lista é crescente monotonicamente e contém o valor 100.
  • getZoomLevel() retorna o nível de zoom atual da guia.
  • setZoomLevel() define o nível de zoom da guia como qualquer valor inteiro presente em getSupportedZoomLevels() e retorna uma promessa quando ela é bem-sucedida. Observe que o nível de zoom não é redefinido no final da sessão de captura.
  • O oncapturedzoomlevelchange permite que você ouça as mudanças do nível de zoom de uma guia capturada, já que os usuários podem mudar o nível de zoom pelo app de captura ou pela interação direta com a guia capturada.

As chamadas para setZoomLevel() são controladas por permissão. As chamadas para os outros métodos de zoom somente leitura são "sem custo financeiro", assim como a detecção de eventos.

O exemplo a seguir mostra como aumentar o nível de zoom de uma guia capturada em uma sessão de captura já existente:

const zoomIncreaseButton = document.getElementById('zoomInButton');

zoomIncreaseButton.addEventListener('click', async (event) => {
  const levels = CaptureController.getSupportedZoomLevels();
  const index = levels.indexOf(controller.getZoomLevel());
  const newZoomLevel = levels[Math.min(index + 1, levels.length - 1)];

  try {
    await controller.setZoomLevel(newZoomLevel);
  } catch (error) {
    // Inspect the error.
    // ...
  }
});

O exemplo a seguir mostra como reagir às mudanças no nível de zoom de uma guia capturada:

controller.addEventListener('capturedzoomlevelchange', (event) => {
  const zoomLevel = controller.getZoomLevel();
  document.querySelector('#zoomLevelLabel').textContent = `${zoomLevel}%`;
});

Detecção de recursos

Para verificar se o envio de eventos de roda é compatível, use:

if (!!window.CaptureController?.prototype.sendWheel) {
  // CaptureController sendWheel() is supported.
}

Para conferir se o controle de zoom é compatível, use:

if (!!window.CaptureController?.prototype.setZoomLevel) {
  // CaptureController setZoomLevel() is supported.
}

Ativar controle de superfície capturada

A API Captured Surface Control está disponível no Chrome para computadores por trás da flag "Captured Surface Control" e pode ser ativada em chrome://flags/#captured-surface-control.

Esse recurso também está entrando em um teste de origem a partir do Chrome 122 para computadores. Com ele, os desenvolvedores podem ativar o recurso para que os visitantes dos sites coletem dados de usuários reais. Consulte Começar a fazer testes de origem para saber mais sobre os testes de origem e como eles funcionam.

Segurança e privacidade

A política de permissão "captured-surface-control" permite gerenciar como o app de captura e os iframes incorporados de terceiros têm acesso ao controle de superfície capturada. Para entender as compensações de segurança, consulte a seção Considerações sobre privacidade e segurança da explicação do "Captured Surface Control".

Demonstração

Você pode testar o recurso "Captured Surface Control" executando a demo no Glitch. Não deixe de conferir o código-fonte.

Alterações de versões anteriores do Chrome

Aqui estão algumas das principais diferenças comportamentais sobre o controle de superfície capturada que você deve conhecer:

  • No Chrome 124 e em versões anteriores:
    • A permissão, se concedida, tem o escopo definido para a sessão de captura associada a esse CaptureController, não a origem de captura.
  • No Chrome 122:
    • getZoomLevel() retorna uma promessa com o nível de zoom atual da guia.
    • sendWheel() vai retornar uma promessa rejeitada com a mensagem de erro "No permission." se o usuário não tiver concedido permissão ao app. O tipo de erro é "NotAllowedError" no Chrome 123 e posterior.
    • oncapturedzoomlevelchange não está disponível. É possível aplicar o polyfill a esse recurso usando setInterval().

Feedback

A equipe do Chrome e a comunidade de padrões da Web querem saber sobre suas experiências com o controle de superfície capturada.

Fale sobre o design

Alguma coisa na captura de superfície capturada não funciona como esperado? Ou há métodos ou propriedades ausentes que você precisa para implementar sua ideia? Tem alguma dúvida ou comentário sobre o modelo de segurança? Registre um problema específico no repositório do GitHub (link em inglês) ou adicione suas ideias a um problema atual.

Problemas com a implementação?

Você encontrou um bug na implementação do Chrome? Ou a implementação é diferente da especificação? Registre um bug em https://new.crbug.com. Inclua o máximo de detalhes possível e instruções para reproduzir o bug. O Glitch funciona muito bem para compartilhar bugs reproduzíveis.