Barra de progresso de upload com XMLHttpRequest que não mente no 100%
Mostre a porcentagem real do envio, troque para um estado de processamento quando os bytes acabam, trate falha e cancelamento e evite arquivo duplicado no reenvio.
A barra chega a 100% e a tela fica parada em "Enviando..." por mais 15 segundos. A pessoa acha que travou, recarrega, envia de novo, e o servidor fica com dois vídeos iguais. A barra não mentiu sobre os bytes: eles saíram mesmo. Ela mentiu ao sugerir que tinha acabado. A resposta curta: use o evento progress de xhr.upload para a porcentagem do envio, troque para um estado indeterminado ("conferindo o arquivo") quando o envio termina, e só diga "pronto" quando o servidor responder 2xx. Um identificador por envio evita a duplicata quando a pessoa tenta de novo. Para tratar o caso em que a barra chega a 100% e o arquivo falha, leia também sobre como mostrar o erro de cada upload.
O exemplo deste guia é um portal de imobiliária em que corretores sobem vídeos de visita, de uns 48 MB. Numa conexão com 10 Mbps de upload, o envio leva cerca de 40 segundos (48 × 8 = 384 megabits, divididos por 10). Depois disso, o servidor ainda confere o tipo, gera miniatura e grava no armazenamento, o que leva mais 10 a 15 segundos.
Por que o 100% chega antes do fim
Um upload tem duas fases. Na primeira, o navegador transmite o corpo da requisição, e ele sabe exatamente quantos bytes já mandou e quantos faltam. Na segunda, o servidor recebeu tudo e trabalha: valida, move, processa e só então responde. Dessa segunda fase o navegador não sabe nada até a resposta chegar.
O evento de progresso descreve só a primeira fase. Quando ele chega a 100%, o navegador terminou de enviar, não o servidor de processar. Se a interface usa a mesma barra para as duas fases, ela fica cheia e parada justamente na parte em que a pessoa mais precisa de informação.
Um detalhe sobre o total: o total do evento é o tamanho do corpo inteiro, incluindo os separadores do multipart/form-data. Ele é um pouco maior que o arquivo, o que não muda nada para a barra, mas explica por que o número não bate exatamente com o tamanho no disco.
Por que não fetch
O fetch é o padrão para requisições hoje, mas ainda não expõe progresso de envio de forma padronizada entre os navegadores. Para mostrar a porcentagem do upload, o XMLHttpRequest continua sendo a ferramenta certa, com xhr.upload emitindo os eventos do envio e o próprio xhr emitindo os da resposta.
Como implementar as duas fases
<form id="form-video">
<label for="video">Vídeo da visita</label>
<input id="video" name="video" type="file" accept="video/mp4" required>
<button type="submit" id="enviar">Enviar</button>
<button type="button" id="cancelar" hidden>Cancelar</button>
</form>
<progress id="barra" max="100" value="0" hidden></progress>
<p id="estado" role="status"></p>
const form = document.getElementById('form-video');
const botaoEnviar = document.getElementById('enviar');
const botaoCancelar = document.getElementById('cancelar');
const barra = document.getElementById('barra');
const estado = document.getElementById('estado');
let idEnvio = null;
let xhrAtual = null;
function enviar(arquivo) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhrAtual = xhr;
const dados = new FormData();
dados.append('video', arquivo);
xhr.open('POST', '/upload-video.php');
xhr.setRequestHeader('Idempotency-Key', idEnvio);
xhr.timeout = 10 * 60 * 1000;
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) return;
const pct = Math.floor((e.loaded / e.total) * 100);
barra.value = pct;
estado.textContent = 'Enviando: ' + pct + '%';
});
xhr.upload.addEventListener('load', () => {
barra.removeAttribute('value');
estado.textContent = 'Vídeo recebido. Conferindo o arquivo no servidor...';
botaoCancelar.hidden = true;
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error('O servidor recusou o envio (HTTP ' + xhr.status + ').'));
}
});
xhr.addEventListener('error', () => reject(new Error('A conexão caiu durante o envio.')));
xhr.addEventListener('timeout', () => reject(new Error('O envio demorou demais.')));
xhr.addEventListener('abort', () => reject(new Error('Envio cancelado.')));
xhr.send(dados);
});
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const arquivo = document.getElementById('video').files[0];
if (!arquivo) return;
if (!idEnvio) idEnvio = crypto.randomUUID();
botaoEnviar.disabled = true;
botaoCancelar.hidden = false;
barra.hidden = false;
barra.value = 0;
try {
const r = await enviar(arquivo);
estado.textContent = 'Pronto. Vídeo salvo com o código ' + r.id + '.';
idEnvio = null;
} catch (erro) {
estado.textContent = erro.message + ' Você pode tentar de novo.';
} finally {
botaoEnviar.disabled = false;
botaoCancelar.hidden = true;
}
});
botaoCancelar.addEventListener('click', () => {
if (xhrAtual) xhrAtual.abort();
});
Tirar o atributo value de um progress o deixa indeterminado: o navegador mostra uma animação de "trabalhando" em vez de uma barra cheia parada. O role="status" faz o leitor de tela anunciar as mudanças de texto. O idEnvio só é zerado no sucesso; se a pessoa tentar de novo depois de uma falha, o mesmo identificador vai junto.
O botão de cancelar some na segunda fase de propósito. Abortar ali só fecha a conexão do lado do navegador, e o servidor pode terminar de gravar o vídeo mesmo assim. Prometer cancelamento que não cancela é outra forma de a barra mentir.
O servidor reconhece a segunda tentativa
<?php
// upload-video.php (trecho)
$chave = $_SERVER['HTTP_IDEMPOTENCY_KEY'] ?? '';
if (!preg_match('/^[0-9a-f-]{36}$/', $chave)) {
http_response_code(400);
exit;
}
$stmt = $pdo->prepare('SELECT video_id FROM envios WHERE chave = ?');
$stmt->execute([$chave]);
$existente = $stmt->fetchColumn();
if ($existente !== false) {
header('Content-Type: application/json');
echo json_encode(['id' => (int) $existente, 'repetido' => true]);
exit;
}
// ... valida $_FILES['video'], move o arquivo, grava o vídeo e obtém $videoId ...
$pdo->prepare('INSERT INTO envios (chave, video_id) VALUES (?, ?)')->execute([$chave, $videoId]);
header('Content-Type: application/json');
echo json_encode(['id' => $videoId]);
Com a coluna chave como UNIQUE, duas requisições simultâneas com o mesmo identificador não criam dois registros. É a mesma ideia de idempotência no pagamento, aplicada a arquivo. E o upload que caiu no meio? O PHP descarta o arquivo temporário de um envio incompleto ao fim da requisição; o risco de lixo aparece quando você implementa envio em partes por conta própria e precisa limpar as partes órfãs.
Se o vídeo passa do limite configurado, a requisição falha antes de chegar ao seu código, e a mensagem precisa refletir isso. Os vários limites envolvidos estão em limite de upload de verdade.
Erros comuns
Checklist
Se o formulário aceita vários arquivos, veja também como deixar a pessoa revisar a fila antes do envio. Agora suba um arquivo grande no seu sistema, corte o Wi-Fi quando a barra passar de 90% e olhe a tela: ela diz que falhou, ou diz "enviado"?
Referência: http://pt.stackoverflow.com/questions/660/como-pegar-o-valor-da-porcentagem-atual-de-um-upload