Pular para o conteúdo
JavaScript

Testando câmera e microfone no navegador

Paloma Macetko
Um unicórnio posando diante de uma câmera mágica sobre um tripé, uma coruja segurando um microfone com ondas sonoras em linhas de luz, e uma janela de navegador flutuante mostrando a prévia

Olá meus Unicórnios! 🦄✨

Toda vez que alguém me diz "a câmera não funciona no seu site", a primeira coisa que eu preciso descobrir é: não funciona onde? A pessoa negou a permissão? O notebook não tem microfone? Ou tem outro programa segurando a webcam? São três problemas completamente diferentes, e o navegador conta qual é — só que num idioma que ninguém lê. 😅

Então resolvi montar a coisa mais simples possível: uma página que liga a câmera, mostra a prévia, grava alguns segundos e deixa você assistir. Se isso funciona, o equipamento está bom. Se não funciona, a página diz por quê, em português.

O melhor: não precisa de biblioteca nenhuma. getUserMedia, MediaRecorder e URL.createObjectURL já vêm dentro do navegador. É HTML, CSS e JavaScript puro — um arquivo só, que você abre e lê de cima a baixo.

🔒 A armadilha que pega todo mundo antes da primeira linha

Vou começar pelo fim, porque é aqui que a maioria das pessoas desiste.

getUserMedia só existe em HTTPS ou em localhost. Em mais nenhum lugar.

Repare no detalhe cruel: quando você abre a página pelo IP da máquina na rede local — aquele endereço que você usa para testar no celular —, o navegador não avisa que o endereço é o problema. Ele simplesmente não cria o objeto navigator.mediaDevices. Ele fica undefined. E aí o seu código morre assim:

TypeError: Cannot read properties of undefined (reading 'getUserMedia')

Um TypeError. Nada sobre HTTPS, nada sobre permissão, nada sobre câmera. Você passa a tarde procurando o erro na sua lógica, e o problema era o endereço da barra. 😳

Por isso a primeira coisa que a página faz é conferir se o objeto existe:

// Sem isso o proximo passo estoura com "Cannot read properties of undefined".
// navigator.mediaDevices simplesmente NAO EXISTE fora de HTTPS ou localhost.
if (!navigator.mediaDevices) {
  avisar('Esta pagina precisa estar em HTTPS ou em localhost para acessar a camera.', true);
  return;
}

📹 Pedindo a permissão

Com a guarda no lugar, o pedido em si é uma linha só. Você diz o que quer, o navegador mostra aquela caixinha para a pessoa aceitar, e você recebe um stream — que é o áudio e o vídeo chegando ao vivo.

streamAtual = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });

Repare que eu guardo o resultado numa variável declarada fora das funções. Isso não é preguiça: na hora de desligar, eu preciso alcançar exatamente estas trilhas de novo. Já volto nisso, porque é a parte que mais gente esquece.

Para a prévia aparecer na tela, o stream vai direto para o <video> — e não pelo src, como seria com um arquivo, e sim pelo srcObject:

previa.srcObject = streamAtual;

E o elemento precisa de três atributos que parecem enfeite e não são:

<video id="previa" autoplay muted playsinline></video>

O autoplay porque senão a imagem fica congelada esperando um play que ninguém vai dar. O muted porque sem ele o seu microfone sai pela sua caixa de som e volta pelo microfone — aquela microfonia horrorosa que faz todo mundo tirar o fone às pressas. 🔊 E o playsinline porque, sem ele, o iPhone abre o vídeo em tela cheia por conta própria.

🚨 Os três erros que o navegador conta (e ninguém traduz)

Se você só for ler um pedaço deste artigo, leia este. 🙏

Quando algo dá errado, o navegador joga um erro com um campo name. Esse nome é padronizado e diz exatamente qual dos problemas aconteceu. A mensagem que vem junto, não: ela muda de navegador para navegador e até de idioma. Olhe sempre o name, nunca a message.

// Traduz o erro do navegador para uma frase que a pessoa entenda.
// O que importa aqui e o `name`, nunca a `message`: a mensagem muda de
// navegador para navegador e ate de idioma, o nome nao.
function explicarErro(erro) {
  if (erro.name === 'NotAllowedError') {
    return 'Voce negou o acesso. Clique no cadeado da barra de enderecos e permita camera e microfone.';
  }
  if (erro.name === 'NotFoundError') {
    return 'Nenhuma camera ou microfone foi encontrado neste computador.';
  }
  if (erro.name === 'NotReadableError') {
    return 'O dispositivo existe, mas outro programa esta usando ele. Feche o outro programa e tente de novo.';
  }
  if (erro.name === 'OverconstrainedError') {
    return 'Nenhum dispositivo atende ao que foi pedido. Tente sem exigir resolucao.';
  }
  return 'Nao consegui abrir a camera: ' + erro.name;
}

Cada um desses nomes é um problema diferente, com uma solução diferente:

  • NotAllowedError — a pessoa clicou em "bloquear". E aqui tem um detalhe: o navegador lembra dessa escolha. Mandar tentar de novo não adianta nada, porque a caixinha não vai mais aparecer. Por isso a mensagem manda ir no cadeado da barra de endereços — é o único lugar onde dá para desfazer.
  • NotFoundError — não existe o equipamento. Desktop sem webcam, máquina virtual, notebook com a câmera desativada na BIOS.
  • NotReadableErroreste é o campeão de suporte. O equipamento existe, a permissão foi dada, e mesmo assim não abre: tem outro programa segurando a câmera. O aplicativo de reunião que ficou aberto atrás, a outra aba do navegador, o programinha da própria fabricante da webcam.
  • OverconstrainedError — você exigiu algo que o equipamento não faz, tipo uma resolução específica. Neste tutorial eu peço video: true justamente para não cair nisso.

Rodando a função com um erro de cada nome, é isso que a pessoa lê na tela:

NotAllowedError        -> Voce negou o acesso. Clique no cadeado da barra de enderecos e permita camera e microfone.
NotFoundError          -> Nenhuma camera ou microfone foi encontrado neste computador.
NotReadableError       -> O dispositivo existe, mas outro programa esta usando ele. Feche o outro programa e tente de novo.
OverconstrainedError   -> Nenhum dispositivo atende ao que foi pedido. Tente sem exigir resolucao.
AbortError             -> Nao consegui abrir a camera: AbortError

Repare na última linha: um nome que eu não previ não vira tela branca nem "erro desconhecido". Ele aparece com o nome, e esse nome é pesquisável. É o mínimo de educação com quem for te mandar o print. 😄

🏷️ Por que a lista de dispositivos vem sem nome

Dá para perguntar ao navegador quais câmeras e microfones existem, com enumerateDevices(). E aqui mora uma pegadinha ótima.

Se você chamar essa função antes de a permissão ser concedida, ela responde — com a lista certa, o número certo de aparelhos — só que o campo label de cada um vem vazio. String em branco.

Não é bug. É privacidade: saber que você tem "Webcam Ultra Pro 4K" e três microfones diz muito sobre a sua máquina, e um site qualquer não precisa disso antes de você autorizar. Os nomes só aparecem depois do getUserMedia.

Por isso eu chamo a listagem depois da permissão, e mesmo assim trato o campo vazio:

// Chamamos isto DEPOIS do getUserMedia de proposito: antes da permissao,
// o navegador devolve a lista com o campo `label` vazio, para nao contar
// quais equipamentos voce tem.
async function listarDispositivos() {
  var dispositivos = await navigator.mediaDevices.enumerateDevices();

  listaDispositivos.innerHTML = '';

  for (var i = 0; i < dispositivos.length; i++) {
    var aparelho = dispositivos[i];
    if (aparelho.kind !== 'videoinput' && aparelho.kind !== 'audioinput') {
      continue;
    }
    var item = document.createElement('li');
    var tipo = 'Microfone';
    if (aparelho.kind === 'videoinput') {
      tipo = 'Camera';
    }
    var nome = aparelho.label;
    if (nome === '') {
      nome = '(sem nome ate a permissao ser dada)';
    }
    item.textContent = tipo + ': ' + nome;
    listaDispositivos.appendChild(item);
  }
}

Alimentando essa função com uma lista de mentira, onde o segundo aparelho está sem nome de propósito, sai isto:

Camera: Camera Generica HD
Microfone: (sem nome ate a permissao ser dada)

⏺️ Gravando: o vídeo não chega pronto

Agora a parte que eu achei que seria a mais difícil e acabou sendo a mais curta. Para gravar, você entrega o mesmo stream ao MediaRecorder.

Só que tem um jeito de pensar aqui que precisa mudar. O vídeo não chega pronto no fim. Ele chega em pedaços, ao vivo, enquanto a gravação acontece. Cada pedaço dispara um evento, e é você quem tem de guardá-los todos. Se você perder um, o arquivo sai truncado.

function comecarGravacao() {
  pedacos = [];
  gravador = new MediaRecorder(streamAtual);

  // O video nao chega pronto no fim: chega em pedacos, e cada pedaco
  // dispara este evento. Se voce nao guardar todos, o arquivo sai truncado.
  gravador.ondataavailable = function (evento) {
    if (evento.data.size > 0) {
      pedacos.push(evento.data);
    }
  };

  // So aqui o video existe por inteiro. Montar o Blob dentro do
  // ondataavailable daria um arquivo com um pedaco so.
  gravador.onstop = function () {
    var arquivo = new Blob(pedacos, { type: gravador.mimeType });
    reproducao.src = URL.createObjectURL(arquivo);
    avisar('Gravacao pronta. Da o play ali embaixo para conferir.');
  };

  gravador.start();
  avisar('Gravando... fale alguma coisa e depois clique em Parar.');

  btnGravar.disabled = true;
  btnParar.disabled = false;
}

São três momentos, e a ordem importa:

  1. ondataavailable — vai disparando e você só empilha. Não monta arquivo aqui. Montar o Blob dentro desse evento é o erro clássico: você acaba com um arquivo do tamanho de um pedaço só, que abre e mostra meio segundo de vídeo.
  2. onstopagora sim. Todos os pedaços chegaram, e o new Blob(pedacos) costura tudo num arquivo só.
  3. URL.createObjectURL(arquivo) — transforma esse arquivo, que só existe na memória, num endereço que o <video> consegue abrir. Nada sobe para servidor nenhum: é tudo dentro do navegador.

Aquele if (evento.data.size > 0) não é frescura. Alguns navegadores mandam um pedaço vazio no fim, e ele não some sozinho da lista.

Entregando três pedaços a essa função e mandando parar, dá para ver a costura acontecendo:

aviso: Gravando... fale alguma coisa e depois clique em Parar.
aviso: Gravacao pronta. Da o play ali embaixo para conferir.
pedacos guardados : 3
tamanho do Blob   : blob:falso/3600 (soma dos 3 pedacos = 3600)
tipo do arquivo   : video/webm

Três pedaços entraram, um arquivo de 3600 bytes saiu — a soma exata. É essa conta que prova que nenhum pedaço ficou pelo caminho.

Repare também no gravador.mimeType: eu não escolho o formato, eu pergunto qual ele usou. Se você chutar um formato que o navegador não gera, o arquivo sai com a extensão errada e não abre.

💡 A luzinha que fica acesa

E chegamos ao meu erro favorito, porque é o que eu mesma cometi. 😳

Terminei o teste, limpei a prévia com previa.srcObject = null, a imagem sumiu da tela. Pronto, né? Aí eu olho para o notebook e a luzinha da webcam continua acesa. A câmera segue ligada, me filmando, com a página achando que já acabou.

Zerar o srcObject só desconecta o vídeo da tela. Não desliga coisa nenhuma. Quem segura o equipamento são as trilhas — uma para o vídeo, outra para o áudio — e cada uma precisa ser parada individualmente:

// A luzinha da webcam so apaga quando TODAS as trilhas param.
// Zerar o srcObject do <video> nao desliga nada: a trilha continua aberta.
function desligarTudo() {
  var trilhas = streamAtual.getTracks();
  for (var i = 0; i < trilhas.length; i++) {
    trilhas[i].stop();
  }
  streamAtual = null;
  previa.srcObject = null;

  avisar('Camera e microfone desligados.');
  btnLigar.disabled = false;
  btnGravar.disabled = true;
  btnParar.disabled = true;
  btnDesligar.disabled = true;
}

Todas as trilhas. Se você parar só a de vídeo, a luz apaga e o microfone continua ouvindo — que é ainda pior, porque não tem luzinha nenhuma para te avisar. 🎤

E é aqui que aquela variável lá de cima faz sentido: sem guardar o stream fora das funções, na hora de desligar você não tem mais como alcançar as trilhas que abriu.

🧩 A página inteira

Junta tudo e é isto — um arquivo, sem instalar nada. Salve como teste-media.html, abra por localhost e clique nos botões na ordem.

<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Teste de camera e microfone</title>
<style>
  body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
  video { width: 100%; background: #000; border-radius: 8px; }
  button { font-size: 1rem; padding: .6rem 1.2rem; margin-right: .5rem; }
  #recado { padding: .8rem; border-radius: 6px; background: #eee; }
  .erro { background: #ffe0e0; }
</style>
</head>
<body>

<h1>Teste de camera e microfone</h1>

<p id="recado">Clique em "Ligar a camera" para comecar.</p>

<video id="previa" autoplay muted playsinline></video>

<p>
  <button id="btnLigar">Ligar a camera</button>
  <button id="btnGravar" disabled>Gravar</button>
  <button id="btnParar" disabled>Parar</button>
  <button id="btnDesligar" disabled>Desligar</button>
</p>

<h2>O que gravei</h2>
<video id="reproducao" controls playsinline></video>

<h2>Dispositivos encontrados</h2>
<ul id="listaDispositivos"></ul>

<script>
// Guardamos o stream numa variavel de fora das funcoes porque na hora de
// desligar precisamos alcancar as mesmas trilhas que foram ligadas.
var streamAtual = null;
var gravador = null;
var pedacos = [];

var recado = document.getElementById('recado');
var previa = document.getElementById('previa');
var reproducao = document.getElementById('reproducao');
var listaDispositivos = document.getElementById('listaDispositivos');
var btnLigar = document.getElementById('btnLigar');
var btnGravar = document.getElementById('btnGravar');
var btnParar = document.getElementById('btnParar');
var btnDesligar = document.getElementById('btnDesligar');

function avisar(texto, ehErro) {
  recado.textContent = texto;
  if (ehErro) {
    recado.className = 'erro';
  } else {
    recado.className = '';
  }
}

// Traduz o erro do navegador para uma frase que a pessoa entenda.
// O que importa aqui e o `name`, nunca a `message`: a mensagem muda de
// navegador para navegador e ate de idioma, o nome nao.
function explicarErro(erro) {
  if (erro.name === 'NotAllowedError') {
    return 'Voce negou o acesso. Clique no cadeado da barra de enderecos e permita camera e microfone.';
  }
  if (erro.name === 'NotFoundError') {
    return 'Nenhuma camera ou microfone foi encontrado neste computador.';
  }
  if (erro.name === 'NotReadableError') {
    return 'O dispositivo existe, mas outro programa esta usando ele. Feche o outro programa e tente de novo.';
  }
  if (erro.name === 'OverconstrainedError') {
    return 'Nenhum dispositivo atende ao que foi pedido. Tente sem exigir resolucao.';
  }
  return 'Nao consegui abrir a camera: ' + erro.name;
}

async function pedirPermissao() {
  // Sem isso o proximo passo estoura com "Cannot read properties of undefined".
  // navigator.mediaDevices simplesmente NAO EXISTE fora de HTTPS ou localhost.
  if (!navigator.mediaDevices) {
    avisar('Esta pagina precisa estar em HTTPS ou em localhost para acessar a camera.', true);
    return;
  }

  avisar('Pedindo permissao...');

  try {
    streamAtual = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  } catch (erro) {
    avisar(explicarErro(erro), true);
    return;
  }

  previa.srcObject = streamAtual;
  avisar('Camera ligada. Agora voce pode gravar.');

  btnLigar.disabled = true;
  btnGravar.disabled = false;
  btnDesligar.disabled = false;

  await listarDispositivos();
}

// Chamamos isto DEPOIS do getUserMedia de proposito: antes da permissao,
// o navegador devolve a lista com o campo `label` vazio, para nao contar
// quais equipamentos voce tem.
async function listarDispositivos() {
  var dispositivos = await navigator.mediaDevices.enumerateDevices();

  listaDispositivos.innerHTML = '';

  for (var i = 0; i < dispositivos.length; i++) {
    var aparelho = dispositivos[i];
    if (aparelho.kind !== 'videoinput' && aparelho.kind !== 'audioinput') {
      continue;
    }
    var item = document.createElement('li');
    var tipo = 'Microfone';
    if (aparelho.kind === 'videoinput') {
      tipo = 'Camera';
    }
    var nome = aparelho.label;
    if (nome === '') {
      nome = '(sem nome ate a permissao ser dada)';
    }
    item.textContent = tipo + ': ' + nome;
    listaDispositivos.appendChild(item);
  }
}

function comecarGravacao() {
  pedacos = [];
  gravador = new MediaRecorder(streamAtual);

  // O video nao chega pronto no fim: chega em pedacos, e cada pedaco
  // dispara este evento. Se voce nao guardar todos, o arquivo sai truncado.
  gravador.ondataavailable = function (evento) {
    if (evento.data.size > 0) {
      pedacos.push(evento.data);
    }
  };

  // So aqui o video existe por inteiro. Montar o Blob dentro do
  // ondataavailable daria um arquivo com um pedaco so.
  gravador.onstop = function () {
    var arquivo = new Blob(pedacos, { type: gravador.mimeType });
    reproducao.src = URL.createObjectURL(arquivo);
    avisar('Gravacao pronta. Da o play ali embaixo para conferir.');
  };

  gravador.start();
  avisar('Gravando... fale alguma coisa e depois clique em Parar.');

  btnGravar.disabled = true;
  btnParar.disabled = false;
}

function pararGravacao() {
  gravador.stop();
  btnParar.disabled = true;
  btnGravar.disabled = false;
}

// A luzinha da webcam so apaga quando TODAS as trilhas param.
// Zerar o srcObject do <video> nao desliga nada: a trilha continua aberta.
function desligarTudo() {
  var trilhas = streamAtual.getTracks();
  for (var i = 0; i < trilhas.length; i++) {
    trilhas[i].stop();
  }
  streamAtual = null;
  previa.srcObject = null;

  avisar('Camera e microfone desligados.');
  btnLigar.disabled = false;
  btnGravar.disabled = true;
  btnParar.disabled = true;
  btnDesligar.disabled = true;
}

btnLigar.onclick = pedirPermissao;
btnGravar.onclick = comecarGravacao;
btnParar.onclick = pararGravacao;
btnDesligar.onclick = desligarTudo;
</script>

</body>
</html>

Quatro botões, na ordem em que se usa: ligar, gravar, parar, desligar. O disabled de cada um sai ligando e desligando conforme o estado — é o que impede a pessoa de clicar em "Gravar" antes de existir stream, que daria um erro feio à toa.

🎁 O que você ganha com isso

Parece pouco: uma página com quatro botões e uma listinha. Mas ela responde, em dez segundos, a pergunta que antes custava meia hora de conversa — "o problema é o seu equipamento, a sua permissão, ou outro programa?".

E o mais bonito é que tudo isso já estava dentro do navegador o tempo todo. Zero dependência, zero servidor, zero upload. O vídeo que você grava nunca sai da sua máquina. ✨

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também