Testando câmera e microfone no navegador
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.NotReadableError— este é 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çovideo: truejustamente 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:
ondataavailable— vai disparando e você só empilha. Não monta arquivo aqui. Montar oBlobdentro 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.onstop— agora sim. Todos os pedaços chegaram, e onew Blob(pedacos)costura tudo num arquivo só.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
Node.js: populando uma planilha no Google Sheets
Como escrever dados numa planilha do Google Sheets com Node.js, e as tres armadilhas que transformam dado certo em planilha errada.
Node.js: consultando CPF e CNPJ no SPC
Como consultar CPF e CNPJ no SPC Brasil com Node.js puro: envelope SOAP na mao, sem biblioteca, e as armadilhas do caminho.
Teste de velocidade da internet no navegador
Como medir download, upload e latencia da conexao com JavaScript puro: o cache que mente, o fator 8 dos bits e a mediana da latencia.