Player de vídeo em React: legendas, velocidade e métricas de visualização
Crie um player de vídeo para cursos e mídia com o Claude Code: reprodução, legendas, acessibilidade e métricas, com código React real.
No dia seguinte a publicar as aulas em vídeo de um curso pago, recebi um único e-mail de um aluno: “Dá pra assistir em 1,25x?”
Na época eu usava uma biblioteca de player de vídeo bonita de ver. Tinha botão de velocidade, sim. Mas a posição das legendas quebrava no celular, a posição de retomada não era salva e eu não fazia ideia de onde os alunos abandonavam o vídeo no meio. Resumindo: dava pra reproduzir, mas não dava pra usar como curso.
No fim, joguei a biblioteca fora e refiz tudo a partir do elemento nativo <video>. Hoje vou escrever essa implementação inteira, com código React que você copia e cola e funciona.
Pontos principais
- Player de vídeo não é “botão de play”. É a experiência inteira: posição de retomada, legendas, velocidade e métricas. É isso que define a taxa de conclusão e o faturamento.
- A base é o elemento nativo
<video>. SincronizarcurrentTimeepauseddoHTMLMediaElementcom o React é o coração de uma implementação estável. - O estado do React não muda no chute, na hora em que você aperta o botão. Ele se sincroniza a partir dos eventos
play/pause. Como a restrição de autoplay fazvideo.play()falhar, otry/catché obrigatório. - Acessibilidade aqui é só “devolver a operação que você escondeu ao ocultar os controles nativos”.
buttonde verdade,input type="range"erole="alert"já bastam. - Métrica não se envia a cada segundo. Sete pontos resolvem: início, 25/50/75%, conclusão, erro e clique no CTA.
Player de vídeo não é “botão de play”
Player de vídeo é a UI que carrega um arquivo ou stream de vídeo e deixa o leitor controlar reprodução, pausa, busca (seek), volume, legendas e velocidade. Na web, quase sempre a base é o elemento nativo <video>, e do JavaScript você lê e escreve propriedades do HTMLMediaElement como currentTime, duration, paused, volume, muted e playbackRate.
Coloco a definição primeiro por um motivo: o player não é enfeite.
Num produto de aprendizado, se o aluno consegue retomar de onde parou, se as legendas ajudam a entender, se dá pra baixar a velocidade para revisar — isso vai direto para a taxa de conclusão. O e-mail da abertura era exatamente isso. Num site de mídia, importa não atrapalhar a exibição inicial do artigo, não deixar o leitor esperando na conexão móvel e levar quem terminou de assistir, de forma natural, para o próximo artigo ou o cadastro. Numa demo de produto SaaS, a própria experiência de reprodução vira confiança.
Antes de implementar, vale dar uma olhada na referência do elemento <video> e na API HTMLMediaElement na MDN. Depois você passa menos tempo perguntando “o que essa propriedade faz mesmo?”. O Claude Code é ótimo para deixar componente, teste, revisão de acessibilidade e eventos de métrica todos alinhados de uma vez. Só que “qual experiência você vai proteger” é decisão do lado do produto — isso não dá pra terceirizar para a IA.
Como escolher entre nativo, customizado e streaming
A primeira coisa a decidir não é a aparência dos botões, e sim o modo de reprodução. Pule essa etapa e você acaba refazendo a base inteira depois (foi o que aconteceu comigo).
O <video controls> nativo é o que publica mais rápido, e o navegador cuida da operação por teclado e da exibição básica de legendas. Por outro lado, se você precisa salvar progresso, mostrar CTA para assinantes, fazer capítulos ou medição própria, vá de controles customizados manipulando o HTMLMediaElement direto. Se o vídeo é longo, há muitos espectadores ou grande variação de país e rede, entra também streaming com HLS, DASH ou um serviço de distribuição de vídeo.
| Modo | Quando usar | O que observar em produção |
|---|---|---|
<video controls> nativo | Embed curto no artigo, documento interno, LP simples | Implementação rápida. Identidade visual e métrica detalhada são limitadas, mas a confiabilidade do básico é alta |
Controles customizados sobre HTMLMediaElement | Produto de aprendizado, mídia, demo de SaaS, vídeo para assinantes | Você controla UI, posição de retomada, exibição de CTA e análise. Em troca, assume a responsabilidade por acessibilidade e tratamento de erros |
| HLS/DASH ou plataforma de distribuição | Cursos longos, catálogo grande, ao vivo, vídeo que precisa de proteção | Exige projetar conversão, manifesto, CDN, bitrate, autorização e biblioteca de reprodução |
Para fazer o primeiro player com o Claude Code, o realista é começar com um MP4 curto, preload="metadata", imagem de poster e arquivo de legenda. Quando precisar de links de capítulo, busca em legenda, taxa de conclusão, exibição para compradores ou integração com SSO interno, parta para o customizado. E migre para streaming no momento em que ficar claro que “um único MP4 não dá conta”. Seguir essa ordem evita carregar uma infraestrutura exagerada logo de cara.
Separe por camadas e os pedidos ficam concretos
Se você pensa na UI de vídeo como um bloco único, toda correção vira um drama. Quebrando em camadas, dá pra fatiar os pedidos ao Claude Code em pedaços pequenos.
| Camada | Papel | O que pedir ao Claude Code para conferir |
|---|---|---|
| Gestão de assets | Preparar MP4/WebM, poster, legendas, manifesto | URL, MIME type, CORS, URL com prazo, texto alternativo |
| Elemento de mídia | Deixar reprodução, carregamento, tempo, legendas e erro a cargo do navegador | preload, playsInline, track, texto de fallback, escuta de eventos |
| Gestão de estado | Refletir currentTime, paused, muted e velocidade no React | Não muda estado no chute, sincroniza a partir dos media events? |
| UI de operação | Oferecer play, seek, volume, velocidade, legendas e tela cheia | Usa button e input, mantém operação por teclado e rótulos? |
| Estado contínuo | Salvar posição de retomada, conclusão, velocidade e mudo | Minimiza o que é salvo, e o site público tem aviso de privacidade? |
| Métricas | Início, 25/50/75%, conclusão, erro, clique no CTA | Não envia a cada segundo, mantém só o que serve para melhorar? |
| Performance | Poster, CDN, lazy loading, bitrate | CLS, transferência inicial, conexão móvel, cabeçalhos de cache |
Com essa divisão, os pedidos ficam concretos: “revise só a acessibilidade da barra de seek”, “ajuste só o tamanho e a estratégia de carregamento do poster”, “teste para o evento de conclusão não disparar duas vezes”. Assim, uma correção pequena na UI de vídeo não acaba arrastando a infraestrutura de distribuição e o design de análise junto para o acidente.
Quatro cenários onde isso faz diferença
1. Aula de um curso pago O aluno sai no meio, volta em outro aparelho e revisa em 1,25x. O que importa não é animação bonita, e sim legendas, posição de retomada, condição de conclusão e o caminho para a próxima aula. O evento de conclusão se envia quando uma porcentagem foi assistida, não quando a página carrega.
2. Embed em artigo de mídia O leitor lê o texto e vai decidindo “assisto ao vídeo ou não?”. Coloque o poster e a transcrição por perto e não carregue o vídeo pesado antes de ser necessário. Para quem terminou de assistir, mostre o caminho para artigos relacionados, newsletter, cadastro ou relatório pago.
3. Demo de produto SaaS Se você troca capítulos por função, página de preços, documentação de API e botão de contato conforme a posição assistida, um vídeo qualquer vira “a UI de explicação antes da reunião comercial”. Mais do que assistir até o fim, importa qual capítulo foi visto e qual CTA foi clicado.
4. Treinamento interno e suporte Para transformar onboarding, compliance e exemplos de atendimento em vídeo, vale reprodução estável sob SSO, legendas, log de auditoria e mensagem clara em caso de erro. O valor não está no brilho, e sim em conseguir conferir, com o mínimo necessário, “quem assistiu até onde”.
Código React/TypeScript que roda na hora
A partir daqui é a peça principal. Dá pra colar direto em Vite, Next.js ou num React island do Astro. Não depende de biblioteca de player externa — funciona só com o <video> nativo e os eventos do HTMLMediaElement.
Decore um ponto só. Não estou reescrevendo o isPlaying do React no instante em que aperto o botão. Eu escuto os eventos play/pause e sincronizo a partir do estado real do navegador. Aquele bug da abertura — “o botão responde, mas o vídeo não toca” — tinha origem exatamente aqui.
import { useEffect, useRef, useState, type ChangeEvent } from "react";
// Definição de uma faixa de legenda
type CaptionTrack = {
src: string;
srcLang: string;
label: string;
default?: boolean;
};
type ProductionVideoPlayerProps = {
src: string;
title: string;
poster?: string;
captions?: CaptionTrack[];
};
// Formata os segundos em algo como 1:05
function formatTime(value: number) {
if (!Number.isFinite(value)) return "0:00";
const minutes = Math.floor(value / 60);
const seconds = Math.floor(value % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}
export function ProductionVideoPlayer({
src,
title,
poster,
captions = [],
}: ProductionVideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(0.8);
const [rate, setRate] = useState(1);
const [error, setError] = useState("");
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// Espelha o estado real do navegador no React (não age no chute)
const syncTime = () => setCurrentTime(video.currentTime);
const syncDuration = () => {
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
};
const syncPlayState = () => setIsPlaying(!video.paused);
const syncVolume = () => setVolume(video.muted ? 0 : video.volume);
video.addEventListener("timeupdate", syncTime);
video.addEventListener("loadedmetadata", syncDuration);
video.addEventListener("durationchange", syncDuration);
video.addEventListener("play", syncPlayState);
video.addEventListener("pause", syncPlayState);
video.addEventListener("volumechange", syncVolume);
return () => {
video.removeEventListener("timeupdate", syncTime);
video.removeEventListener("loadedmetadata", syncDuration);
video.removeEventListener("durationchange", syncDuration);
video.removeEventListener("play", syncPlayState);
video.removeEventListener("pause", syncPlayState);
video.removeEventListener("volumechange", syncVolume);
};
}, []);
async function togglePlay() {
const video = videoRef.current;
if (!video) return;
if (video.paused) {
try {
// Pode falhar pela restrição de autoplay, então sempre use try/catch
await video.play();
setError("");
} catch {
setError("A reprodução foi bloqueada. Aperte play de novo ou verifique as configurações do navegador.");
}
} else {
video.pause();
}
}
function seek(event: ChangeEvent<HTMLInputElement>) {
const video = videoRef.current;
if (!video) return;
const nextTime = Number(event.target.value);
video.currentTime = nextTime;
setCurrentTime(nextTime);
}
function changeVolume(event: ChangeEvent<HTMLInputElement>) {
const video = videoRef.current;
if (!video) return;
const nextVolume = Number(event.target.value);
video.volume = nextVolume;
video.muted = nextVolume === 0;
setVolume(nextVolume);
}
function changeRate(event: ChangeEvent<HTMLSelectElement>) {
const video = videoRef.current;
if (!video) return;
const nextRate = Number(event.target.value);
video.playbackRate = nextRate;
setRate(nextRate);
}
function toggleMute() {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
}
return (
<section className="production-video-player" aria-label={`Player de vídeo: ${title}`}>
<video ref={videoRef} poster={poster} preload="metadata" playsInline>
<source src={src} type={src.endsWith(".webm") ? "video/webm" : "video/mp4"} />
{captions.map((track) => (
<track
key={track.src}
kind="captions"
src={track.src}
srcLang={track.srcLang}
label={track.label}
default={track.default}
/>
))}
Seu navegador não suporta o elemento de vídeo.
</video>
<div role="group" aria-label="Controles do vídeo">
<button type="button" onClick={togglePlay} aria-label={isPlaying ? "Pausar" : "Reproduzir"}>
{isPlaying ? "Pausar" : "Reproduzir"}
</button>
<label>
<span>Buscar</span>
<input
type="range"
min="0"
max={duration || 0}
step="0.1"
value={duration ? currentTime : 0}
onChange={seek}
aria-valuetext={`${formatTime(currentTime)} / ${formatTime(duration)}`}
/>
</label>
<output>
{formatTime(currentTime)} / {formatTime(duration)}
</output>
<button type="button" onClick={toggleMute} aria-label={volume === 0 ? "Tirar do mudo" : "Mudo"}>
{volume === 0 ? "Tirar do mudo" : "Mudo"}
</button>
<label>
<span>Volume</span>
<input type="range" min="0" max="1" step="0.05" value={volume} onChange={changeVolume} />
</label>
<label>
<span>Velocidade</span>
<select value={rate} onChange={changeRate}>
{[0.75, 1, 1.25, 1.5, 2].map((speed) => (
<option key={speed} value={speed}>
{speed}x
</option>
))}
</select>
</label>
</div>
{error ? <p role="alert">{error}</p> : null}
</section>
);
}
Só com esse componente, o “quero assistir em 1,25x” do e-mail inicial já se resolve pela troca de velocidade no <select>. Salvar a posição de retomada é questão de poucas linhas: no timeupdate, guarde o currentTime no localStorage de forma espaçada, e escreva de volta depois do loadedmetadata. Minha recomendação é não querer tudo de uma vez — use isto como base e vá empilhando uma função por vez.
Métricas de visualização: sete pontos bastam
O que eu quero aprofundar aqui é a medição. O maior desperdício na UI de vídeo é mandar evento a cada segundo, transbordar o log e, no fim, não saber onde houve abandono. Foi exatamente o erro que eu cometi primeiro.
O que você precisa enviar são só sete pontos. Início, 25%, 50%, 75%, conclusão, erro e clique no CTA. Dispare cada um dentro do timeupdate, “uma única vez, no instante em que cruza o limiar”.
// Implementação mínima que envia cada evento de progresso só uma vez (adicione no useEffect do componente acima)
const sent = new Set<string>();
const track = (name: string) => {
if (sent.has(name)) return; // Evita envio duplicado do mesmo evento
sent.add(name);
// Troque pelo destino real de envio (GA4 / API própria etc.)
window.dispatchEvent(new CustomEvent("video-metric", { detail: { name, src } }));
};
const onTime = () => {
if (!video.duration) return;
const ratio = video.currentTime / video.duration;
if (ratio >= 0.25) track("p25");
if (ratio >= 0.5) track("p50");
if (ratio >= 0.75) track("p75");
};
video.addEventListener("play", () => track("start"), { once: true });
video.addEventListener("timeupdate", onTime);
video.addEventListener("ended", () => track("complete"));
video.addEventListener("error", () => track("error"));
Gerenciar o que já foi enviado com um Set é o detalhe discreto que segura tudo. O timeupdate dispara várias vezes por segundo, então, sem isso, você acabaria mandando p50 dezenas de vezes. A conclusão se captura pelo evento ended, não na exibição da página. Só com isso, num serviço de aprendizado você enxerga “quais aulas têm baixa taxa de conclusão” e, na mídia, “se a pessoa foi para o cadastro depois de assistir”.
Quando quiser apertar mais o ponto de vista das métricas, a estratégia de testes com Claude Code ajuda a transformar o “não envia duas vezes?” em teste de verdade.
As armadilhas de acessibilidade e performance
A maior armadilha é ter escondido a operação nativa sem devolver uma operação equivalente. Fazer o botão de play com uma div, deixar a barra de seek sem rótulo, não conseguir mudar a velocidade pelo teclado, não ter legendas — por mais bonito que fique, assim não dá pra usar num curso. Botão com button de verdade, seek com input type="range", erro comunicado por role="alert". O básico é só isso. Os pontos detalhados de operação por teclado e legendas eu reuni em como garantir acessibilidade com o Claude Code.
No lado da performance, o perigoso é o design que carrega vários vídeos ao mesmo tempo numa listagem de artigos ou numa LP. Dê largura e altura à imagem de poster, deixe os vídeos de visualização opcional com preload="metadata" e distribua o arquivo principal pela CDN. Entregar um curso longo num único MP4 gigante te deixa em desvantagem em tudo: conexão móvel, audiência no exterior e abandono no meio. Se o peso da exibição inicial te preocupa, junte com como otimizar a performance com o Claude Code. Para o caso de lidar só com áudio, como criar um player de áudio com o Claude Code tem uma estrutura parecida.
A checagem antes de publicar — estes oito itens eu olho toda vez.
- Conferi os rótulos para teclado, toque, mouse e leitor de tela
- Preparei legenda ou transcrição e não tranquei informação importante só no vídeo
- No celular o
playsInlinefunciona e não há transição indesejada para tela cheia - Fixei proporção e tamanho da imagem de poster e não estou causando CLS
- Não coloquei
preload="auto"em vídeo desnecessário para a exibição inicial - Testei URL expirada, legenda faltando, conexão lenta e bloqueio de autoplay
- Defini os nomes das métricas de início, progresso, conclusão, erro e clique no CTA
- Preparei o procedimento para voltar aos
controlsnativos quando a operação customizada quebrar
Perguntas frequentes
P. Player de vídeo: melhor fazer do zero ou usar biblioteca?
Para um embed curto dentro do artigo, <video controls> basta. Se você precisa salvar progresso, CTA, capítulos e métrica própria, fazer do zero (o componente acima) é mais adequado. Para vídeo longo, catálogo grande e ao vivo, vá de biblioteca que assuma streaming. O certo é separar pelos requisitos.
P. Por que o video.play() não reproduz?
É a restrição de autoplay do navegador. Reprodução sem ação do usuário e autoplay com som são bloqueados. Embrulhe sempre o await video.play() em try/catch e, se falhar, peça uma nova ação ou coloque muted e tente de novo.
P. Como implementar a posição de retomada (começar do meio)?
No timeupdate, salve o currentTime no localStorage de forma espaçada e, depois do loadedmetadata, escreva de volta em video.currentTime. No site público, acrescente uma linha de aviso de privacidade sobre o que está sendo salvo.
P. Em que momento enviar as métricas de visualização?
A cada segundo, não. Sete pontos — início, 25/50/75%, conclusão, erro e clique no CTA — cada um uma única vez, evitando o envio duplicado com um Set. O truque é capturar a conclusão pelo evento ended e não na exibição da página.
P. Dica para fazer o Claude Code criar um player de vídeo? Fatie os pedidos em pequenos, como na tabela de camadas. Passando “só a acessibilidade da barra de seek” ou “só o teste de envio duplicado do evento complete”, a correção não arrasta a infraestrutura de distribuição junto. Na hora da revisão, mostrar antes estes três pontos acelera: “sincroniza o estado a partir dos media events?”, “dá pra operar tudo pelo teclado?” e “não está baixando o arquivo principal no carregamento inicial?”.
O resultado de testar na prática
Desde o e-mail do “quero assistir em 1,25x”, parei de tratar o player de vídeo como um problema de “escolha de biblioteca”. O que eu olho, sempre, são estes três pontos: o estado do React sincroniza a partir dos eventos play/pause? O video.play() está protegido por try/catch? As métricas estão reduzidas a sete pontos?
Quando joguei a biblioteca fora e refiz a partir do <video> nativo, a quebra das legendas sumiu, a posição de retomada saiu em poucas linhas e, graças às métricas que pararam o envio duplicado com o Set, eu enxerguei pela primeira vez “em qual aula o abandono é maior”. Uma aula com conclusão baixa, na verdade, só tinha os primeiros 90 segundos longos demais — cortei aquilo e a taxa de quem assistiu até o fim subiu. Em vez de caçar a biblioteca de vídeo mais esperta, somar ao elemento nativo só o que é necessário. Parece o caminho mais longo, mas hoje eu sinto que é o mais rápido.
Quem quer desenhar essa UI de vídeo para o próprio material, treinamento interno ou mídia, comece pela página de treinamento e consultoria. Dá pra acertar juntos desde a revisão de implementação até o design das métricas. Quem prefere testar primeiro por conta tem também a lista de produtos. Se algo aqui te ajudou, deixo aberta a página de agradecimento.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Checklist de permissões antes de Claude Code editar site de cliente
Um quadro para agências usarem IA em landing pages sem tocar áreas sensíveis.
Transforme tickets de suporte SaaS em passos reproduzíveis com Claude Code
Fluxo para converter chamados vagos em reprodução, evidência e nota útil para engenharia.
Rotina de 10 minutos para transformar notas antigas do Obsidian em brief para o Claude Code
Suas notas do Obsidian viram lixo toda sessão? Separe fatos, decisões e dúvidas e transforme-as num brief que o Claude Code executa direto.