"use client";

import { areaDaImagem, type QuadroDeAnotacoes } from "../anotacoes.ts";
import { iniciais } from "../regras.ts";

import type { ApiDaSala } from "./api";

/*
 * A gravação da live, no navegador de quem apresenta.
 *
 * Um quadro (canvas) junta o que está na tela da live, de um de dois jeitos:
 * "palco" (a tela compartilhada com a câmera de quem apresenta no cantinho,
 * ou só a câmera) ou "sala" (a sala inteira, com as câmeras ao lado, como no
 * Meet). Os destaques da caneta entram por cima da tela. O áudio de todo
 * mundo que fala é misturado num só. O codificador do próprio navegador (WebCodecs, pela
 * biblioteca mediabunny) faz um MP4 de verdade, que dá para avançar e voltar.
 *
 * O computador nunca acumula quadros: cada quadro só vai para o codificador
 * quando o anterior já foi aceito; se a máquina não der conta, o quadro é
 * pulado (o vídeo fica com menos quadros naquele trecho, mas a live não
 * trava e parar a gravação termina na hora). Antes, a gravação ia guardando
 * quadros na memória até deixar a câmera e a tela de quem apresentava lentas.
 *
 * O arquivo sobe em partes de 8 MB enquanto a live acontece. O começo do
 * arquivo fica guardado até o fim, porque o MP4 é corrigido ali quando fecha;
 * por isso ele sobe por último (o depósito aceita as partes fora de ordem).
 */

export const TAMANHO_DA_PARTE = 8 * 1024 * 1024;
const LARGURA = 1280;
const ALTURA = 720;
const QUADROS = 20;

/* Uma pessoa como aparece na gravação "sala inteira". */
export type PessoaNoQuadro = {
  id: string;
  nome: string;
  track: MediaStreamTrack | null;
  /* A foto ou a logo da empresa (aparece no lugar das iniciais, como na sala). */
  foto?: string | null;
  falando: boolean;
  mudo: boolean;
  apresenta: boolean;
};

export type FonteDoQuadro = {
  /* "palco": a tela grande com a câmera de quem apresenta no cantinho. "sala": a sala inteira, como no Meet. */
  modo: "palco" | "sala";
  /* A tela compartilhada (quando há). */
  tela: { track: MediaStreamTrack; nome: string } | null;
  /* Quem aparece, na ordem: quem apresenta primeiro. */
  pessoas: PessoaNoQuadro[];
  /* Quantas pessoas a mais estão na sala sem aparecer. */
  mais: number;
  anotacoes: QuadroDeAnotacoes | null;
};

type Parte = { numero: number; dados: Uint8Array; usado: number };

/* Guarda os bytes do arquivo por parte e manda as completas, uma por vez. */
export class EnvioEmPartes {
  private readonly partes = new Map<number, Parte>();
  private alto = 0;
  private enviadasAte = 1; /* a próxima parte que pode subir (a 0 fica para o fim) */
  private fila: Promise<void> = Promise.resolve();
  private readonly enviar: (numero: number, dados: Uint8Array) => Promise<void>;
  private readonly tamanho: number;
  erro: string | null = null;
  /* Uma parte não subiu nem depois das tentativas: o arquivo já tem um buraco, as próximas não sobem. */
  private parou = false;
  bytes = 0;

  constructor(enviar: (numero: number, dados: Uint8Array) => Promise<void>, tamanho = TAMANHO_DA_PARTE) {
    this.enviar = enviar;
    this.tamanho = tamanho;
  }

  escrever(dados: Uint8Array, posicao: number) {
    let lido = 0;
    while (lido < dados.length) {
      const absoluto = posicao + lido;
      const indice = Math.floor(absoluto / this.tamanho);
      const dentro = absoluto - indice * this.tamanho;
      const cabe = Math.min(this.tamanho - dentro, dados.length - lido);
      if (indice > 0 && indice < this.enviadasAte) {
        /* Escreveu numa parte que já subiu: não deveria acontecer com o MP4 que montamos. */
        this.erro = "escrita atrasada";
        lido += cabe;
        continue;
      }
      let parte = this.partes.get(indice);
      if (!parte) {
        parte = { numero: indice + 1, dados: new Uint8Array(this.tamanho), usado: 0 };
        this.partes.set(indice, parte);
      }
      parte.dados.set(dados.subarray(lido, lido + cabe), dentro);
      parte.usado = Math.max(parte.usado, dentro + cabe);
      lido += cabe;
    }
    this.alto = Math.max(this.alto, posicao + dados.length);
    this.bytes = this.alto;
    /* Sobe as partes que ficaram para trás com folga de uma parte inteira. */
    while (this.alto >= (this.enviadasAte + 2) * this.tamanho) {
      this.subir(this.enviadasAte, false);
      this.enviadasAte += 1;
    }
  }

  private subir(indice: number, ultima: boolean) {
    const parte = this.partes.get(indice);
    if (!parte) return;
    this.partes.delete(indice);
    /* Toda parte tem exatamente `tamanho` bytes, menos a última: é a regra do depósito para juntar. */
    const dados = ultima ? parte.dados.subarray(0, parte.usado) : parte.dados;
    this.fila = this.fila
      .then(() => (this.parou ? undefined : this.enviar(parte.numero, dados)))
      .catch((erro) => {
        this.parou = true;
        this.erro = erro instanceof Error ? erro.message : "falha no envio";
      });
  }

  /* No fim: sobe o que sobrou e, por último, o começo do arquivo. */
  async terminar(): Promise<void> {
    const indices = Array.from(this.partes.keys()).filter((i) => i > 0).sort((a, b) => a - b);
    const ultimo = indices[indices.length - 1];
    for (const i of indices) this.subir(i, i === ultimo);
    this.subir(0, indices.length === 0);
    await this.fila;
    if (this.erro) throw new Error(this.erro);
  }

  esperar(): Promise<void> {
    return this.fila;
  }
}

/* Uma recusa que não muda tentando de novo (4xx, menos tempo esgotado e excesso de pedidos). */
export function erroDefinitivo(erro: unknown): boolean {
  /* O ErroDaSala traz o status da resposta (sem importar a classe: este arquivo também roda nos testes). */
  const status = erro instanceof Error ? Number((erro as Error & { status?: unknown }).status) : NaN;
  return status >= 400 && status < 500 && status !== 408 && status !== 429;
}

/* A cor do círculo de quem está sem câmera (a mesma pessoa, a mesma cor). */
export function corDoNome(nome: string): string {
  const cores = ["#5f6368", "#1e8e3e", "#d93025", "#1a73e8", "#e37400", "#9334e6", "#007b83", "#c5221f"];
  let soma = 0;
  for (let i = 0; i < nome.length; i += 1) soma = (soma * 31 + nome.charCodeAt(i)) >>> 0;
  return cores[soma % cores.length];
}

export function gravacaoPossivel(): boolean {
  return typeof window !== "undefined" && "VideoEncoder" in window && "AudioEncoder" in window && "MediaStreamTrackProcessor" in window;
}

export class GravadorDaLive {
  private readonly api: ApiDaSala;
  private readonly fonte: () => FonteDoQuadro;
  private canvas: HTMLCanvasElement | null = null;
  private caixa: HTMLDivElement | null = null;
  private trabalhador: Worker | null = null;
  private readonly videos = new Map<string, HTMLVideoElement>();
  /* As logos já carregadas (null = não deu para carregar: fica com as iniciais). */
  private readonly imagens = new Map<string, HTMLImageElement | null>();

  private imagemDa(url: string | null | undefined): HTMLImageElement | null {
    if (!url) return null;
    if (this.imagens.has(url)) return this.imagens.get(url) ?? null;
    this.imagens.set(url, null);
    const img = new Image();
    img.crossOrigin = "anonymous";
    img.onload = () => this.imagens.set(url, img);
    img.src = url;
    return null;
  }
  private relogio: ReturnType<typeof setInterval> | null = null;
  private audio: AudioContext | null = null;
  private destino: MediaStreamAudioDestinationNode | null = null;
  private readonly entradas = new Map<string, MediaStreamAudioSourceNode>();
  private saida: { finalize: () => Promise<void>; cancel: () => Promise<void> } | null = null;
  private envio: EnvioEmPartes | null = null;
  private comecou = 0;
  private fonteDoVideo: { add: (instante: number, duracao?: number) => Promise<void> } | null = null;
  /* O id da gravação aberta no servidor: vai em cada pedido, para um gravador velho nunca mexer na de agora. */
  private idDaGravacao: string | null = null;
  private codificando = false;
  /* Quadros pulados porque o codificador ainda estava ocupado (diz se a máquina está sofrendo). */
  pulados = 0;
  ativo = false;

  constructor(api: ApiDaSala, fonte: () => FonteDoQuadro) {
    this.api = api;
    this.fonte = fonte;
  }

  /* Liga e desliga o áudio de cada pessoa na mistura (quem fala entra, quem sai sai). */
  misturar(audios: Map<string, MediaStreamTrack>) {
    if (!this.audio || !this.destino) return;
    for (const [id, no] of this.entradas) {
      if (!audios.has(id)) {
        no.disconnect();
        this.entradas.delete(id);
      }
    }
    for (const [id, track] of audios) {
      if (this.entradas.has(id) || track.readyState !== "live") continue;
      const no = this.audio.createMediaStreamSource(new MediaStream([track]));
      no.connect(this.destino);
      this.entradas.set(id, no);
    }
  }

  /* Um <video> escondido por trilha: o quadro desenha deles (não depende do que está na tela). */
  private videoDa(track: MediaStreamTrack): HTMLVideoElement | null {
    if (track.readyState !== "live") return null;
    let video = this.videos.get(track.id);
    if (!video) {
      if (!this.caixa) {
        this.caixa = document.createElement("div");
        this.caixa.setAttribute("aria-hidden", "true");
        this.caixa.style.cssText = "position:fixed;right:0;bottom:0;width:2px;height:2px;overflow:hidden;opacity:0.01;pointer-events:none;z-index:-1";
        document.body.appendChild(this.caixa);
      }
      video = document.createElement("video");
      video.muted = true;
      video.playsInline = true;
      video.autoplay = true;
      video.srcObject = new MediaStream([track]);
      video.style.cssText = "width:2px;height:2px";
      this.caixa.appendChild(video);
      void video.play().catch(() => undefined);
      this.videos.set(track.id, video);
    }
    return video.readyState >= 2 ? video : null;
  }

  private soltarVideos(usadas: Set<string>) {
    for (const [id, video] of this.videos) {
      if (usadas.has(id)) continue;
      video.srcObject = null;
      video.remove();
      this.videos.delete(id);
    }
  }

  private desenhar() {
    const canvas = this.canvas;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;
    const fonte = this.fonte();
    const usadas = new Set<string>();
    const video = (track: MediaStreamTrack | null) => {
      if (!track) return null;
      usadas.add(track.id);
      return this.videoDa(track);
    };
    ctx.fillStyle = "#202124";
    ctx.fillRect(0, 0, LARGURA, ALTURA);

    const caber = (v: HTMLVideoElement, x: number, y: number, l: number, a: number, cobrir: boolean) => {
      const vl = v.videoWidth;
      const va = v.videoHeight;
      if (!vl || !va) return;
      const escala = cobrir ? Math.max(l / vl, a / va) : Math.min(l / vl, a / va);
      const dl = vl * escala;
      const da = va * escala;
      ctx.save();
      ctx.beginPath();
      ctx.rect(x, y, l, a);
      ctx.clip();
      ctx.drawImage(v, x + (l - dl) / 2, y + (a - da) / 2, dl, da);
      ctx.restore();
    };

    const fonteDoTexto = (tamanho: number) => `500 ${tamanho}px system-ui, -apple-system, "Segoe UI", sans-serif`;

    const rotulo = (texto: string, x: number, y: number, tamanho: number) => {
      ctx.font = fonteDoTexto(tamanho);
      ctx.fillStyle = "rgba(0,0,0,.5)";
      const l = ctx.measureText(texto).width + tamanho;
      ctx.beginPath();
      ctx.roundRect(x, y - tamanho * 1.5, l, tamanho * 1.7, 6);
      ctx.fill();
      ctx.fillStyle = "#fff";
      ctx.fillText(texto, x + tamanho / 2, y - tamanho * 0.38);
    };

    /* Uma carinha: a câmera, ou o círculo com as iniciais; o nome embaixo; o anel de quem fala. */
    const carinha = (p: PessoaNoQuadro, x: number, y: number, l: number, a: number) => {
      ctx.save();
      ctx.beginPath();
      ctx.roundRect(x, y, l, a, 10);
      ctx.clip();
      ctx.fillStyle = "#3c4043";
      ctx.fillRect(x, y, l, a);
      const v = video(p.track);
      const logo = v ? null : this.imagemDa(p.foto);
      if (v) caber(v, x, y, l, a, true);
      else if (logo && logo.naturalWidth) {
        /* A logo inteira num círculo branco, como na sala. */
        const raio = Math.min(l, a) * 0.2;
        ctx.fillStyle = "#fff";
        ctx.beginPath();
        ctx.arc(x + l / 2, y + a / 2, raio, 0, Math.PI * 2);
        ctx.fill();
        const lado = raio * 1.44;
        const escala = Math.min(lado / logo.naturalWidth, lado / logo.naturalHeight);
        const ll = logo.naturalWidth * escala;
        const la = logo.naturalHeight * escala;
        ctx.drawImage(logo, x + l / 2 - ll / 2, y + a / 2 - la / 2, ll, la);
      } else {
        const raio = Math.min(l, a) * 0.2;
        ctx.fillStyle = corDoNome(p.nome);
        ctx.beginPath();
        ctx.arc(x + l / 2, y + a / 2, raio, 0, Math.PI * 2);
        ctx.fill();
        ctx.fillStyle = "#fff";
        ctx.font = fonteDoTexto(Math.round(raio * 0.8));
        ctx.textAlign = "center";
        ctx.textBaseline = "middle";
        ctx.fillText(iniciais(p.nome), x + l / 2, y + a / 2 + 1);
        ctx.textAlign = "start";
        ctx.textBaseline = "alphabetic";
      }
      ctx.restore();
      if (p.falando) {
        ctx.strokeStyle = "#8ab4f8";
        ctx.lineWidth = 3;
        ctx.beginPath();
        ctx.roundRect(x + 1.5, y + 1.5, l - 3, a - 3, 9);
        ctx.stroke();
      }
      ctx.save();
      ctx.beginPath();
      ctx.rect(x, y, l, a);
      ctx.clip();
      rotulo(p.nome, x + 8, y + a - 8, l < 260 ? 12 : 15);
      ctx.restore();
    };

    const caixaDeMais = (n: number, x: number, y: number, l: number, a: number) => {
      ctx.fillStyle = "#3c4043";
      ctx.beginPath();
      ctx.roundRect(x, y, l, a, 10);
      ctx.fill();
      ctx.fillStyle = "#e8eaed";
      ctx.font = fonteDoTexto(l < 260 ? 15 : 20);
      ctx.textAlign = "center";
      ctx.fillText(`Mais ${n} ${n === 1 ? "pessoa" : "pessoas"}`, x + l / 2, y + a / 2 + 6);
      ctx.textAlign = "start";
    };

    /* A tela compartilhada (com os destaques por cima) dentro de uma área. */
    const telaNa = (x: number, y: number, l: number, a: number) => {
      const v = video(fonte.tela?.track ?? null);
      if (!v) return;
      caber(v, x, y, l, a, false);
      if (fonte.anotacoes) fonte.anotacoes.desenhar(ctx, areaDaImagem(l, a, v.videoWidth, v.videoHeight, x, y));
    };

    const quemApresenta = fonte.pessoas.find((p) => p.apresenta) ?? null;

    if (fonte.modo === "palco") {
      if (fonte.tela) {
        telaNa(0, 0, LARGURA, ALTURA);
        const cam = quemApresenta?.track ? video(quemApresenta.track) : null;
        if (cam && quemApresenta) {
          const l = 300;
          const a = 188;
          const x = LARGURA - l - 24;
          const y = ALTURA - a - 24;
          ctx.save();
          ctx.beginPath();
          ctx.roundRect(x, y, l, a, 12);
          ctx.clip();
          caber(cam, x, y, l, a, true);
          ctx.restore();
          rotulo(quemApresenta.nome, x + 8, y + a - 8, 14);
        }
      } else if (quemApresenta) {
        carinha({ ...quemApresenta, falando: false }, 0, 0, LARGURA, ALTURA);
      }
    } else if (fonte.tela) {
      /* A tela grande à esquerda e uma coluna de carinhas, como no Meet. */
      const margem = 12;
      const colunaL = 300;
      const palcoL = LARGURA - colunaL - margem * 3;
      telaNa(margem, margem, palcoL, ALTURA - margem * 2);
      const cabem = 4;
      const lista = fonte.pessoas.slice(0, fonte.pessoas.length + fonte.mais > cabem ? cabem - 1 : cabem);
      const sobra = fonte.pessoas.length - lista.length + fonte.mais;
      const slots = lista.length + (sobra > 0 ? 1 : 0);
      const altura = Math.min(190, (ALTURA - margem * (slots + 1)) / Math.max(1, slots));
      const topo = (ALTURA - (altura * slots + margem * (slots - 1))) / 2;
      const x = LARGURA - colunaL - margem;
      lista.forEach((p, i) => carinha(p, x, topo + i * (altura + margem), colunaL, altura));
      if (sobra > 0) caixaDeMais(sobra, x, topo + lista.length * (altura + margem), colunaL, altura);
    } else {
      /* Sem tela: a galeria (até 9 carinhas). */
      const total = fonte.pessoas.length;
      const lista = fonte.pessoas.slice(0, total + fonte.mais > 9 ? 8 : 9);
      const sobra = total - lista.length + fonte.mais;
      const slots = Math.max(1, lista.length + (sobra > 0 ? 1 : 0));
      const colunas = slots <= 1 ? 1 : slots <= 4 ? 2 : 3;
      const linhas = Math.ceil(slots / colunas);
      const margem = 12;
      const l = (LARGURA - margem * (colunas + 1)) / colunas;
      const a = Math.min((ALTURA - margem * (linhas + 1)) / linhas, (l * 9) / 16);
      const topo = (ALTURA - (a * linhas + margem * (linhas - 1))) / 2;
      for (let i = 0; i < slots; i += 1) {
        const linha = Math.floor(i / colunas);
        const naLinha = Math.min(colunas, slots - linha * colunas);
        const esquerda = (LARGURA - (l * naLinha + margem * (naLinha - 1))) / 2;
        const x = esquerda + (i % colunas) * (l + margem);
        const y = topo + linha * (a + margem);
        if (i < lista.length) carinha(lista[i], x, y, l, a);
        else caixaDeMais(sobra, x, y, l, a);
      }
    }
    this.soltarVideos(usadas);
  }

  async iniciar(audioInicial: Map<string, MediaStreamTrack>): Promise<void> {
    if (this.ativo) return;
    const mb = await import("mediabunny");
    this.canvas = document.createElement("canvas");
    this.canvas.width = LARGURA;
    this.canvas.height = ALTURA;
    this.desenhar();

    this.audio = new AudioContext({ sampleRate: 48000 });
    this.destino = this.audio.createMediaStreamDestination();
    /* Um silêncio sempre ligado: sem ele, a trilha de áudio para quando ninguém fala. */
    const silencio = this.audio.createConstantSource();
    const ganho = this.audio.createGain();
    ganho.gain.value = 0;
    silencio.connect(ganho).connect(this.destino);
    silencio.start();
    this.misturar(audioInicial);
    const audio = this.destino.stream.getAudioTracks()[0];

    const codecDeVideo = await mb.getFirstEncodableVideoCodec(["avc", "vp9", "vp8"], { width: LARGURA, height: ALTURA });
    /* MP4 sempre que der (toca em qualquer aparelho); WebM só com VP8, e aí o áudio é Opus. */
    const mp4 = codecDeVideo !== null && codecDeVideo !== "vp8";
    const codecDeAudio = await mb.getFirstEncodableAudioCodec(mp4 ? ["aac", "opus"] : ["opus"], { numberOfChannels: 2, sampleRate: 48000 });
    if (!codecDeVideo || !codecDeAudio) throw new Error("Este navegador não grava. Use o Chrome ou o Edge no computador.");
    const tipo = mp4 ? "video/mp4" : "video/webm";

    const aberta = await this.api.pedir<{ gravacao?: unknown }>("gravacao", { tipo }, { consulta: { acao: "abrir" } });
    this.idDaGravacao = typeof aberta.gravacao === "string" ? aberta.gravacao : null;
    try {
      await this.ligarSaida(mb, { mp4, codecDeVideo, codecDeAudio, audio });
    } catch (erro) {
      /* Abriu no servidor mas não começou aqui: desfaz lá também. */
      await this.api.pedir("gravacao", {}, { consulta: this.consulta("cancelar") }).catch(() => undefined);
      this.limpar();
      throw erro;
    }
  }

  private consulta(acao: string, extra: Record<string, string> = {}): Record<string, string> {
    return { acao, ...extra, ...(this.idDaGravacao ? { g: this.idDaGravacao } : {}) };
  }

  private async ligarSaida(
    mb: typeof import("mediabunny"),
    o: { mp4: boolean; codecDeVideo: import("mediabunny").VideoCodec; codecDeAudio: import("mediabunny").AudioCodec; audio: MediaStreamTrack },
  ) {
    const { mp4, codecDeVideo, codecDeAudio, audio } = o;
    const canvas = this.canvas;
    if (!canvas) throw new Error("A gravação não começou.");
    this.envio = new EnvioEmPartes(async (numero, dados) => {
      let tentativa = 0;
      for (;;) {
        try {
          const corpo = new Uint8Array(dados.byteLength);
          corpo.set(dados);
          await this.api.pedir("gravacao", undefined, { consulta: this.consulta("parte", { n: String(numero) }), binario: corpo.buffer, prazoMs: 120000 });
          return;
        } catch (erro) {
          tentativa += 1;
          /* Recusa de verdade (outra gravação no lugar, gravação fechada, parte inválida): tentar de novo não muda. */
          if (erroDefinitivo(erro) || tentativa >= 5) throw erro;
          await new Promise((r) => setTimeout(r, 1500 * tentativa));
        }
      }
    });
    const envio = this.envio;
    const escrita = new WritableStream<{ type: "write"; data: Uint8Array; position: number }>({
      write: (pedaco) => envio.escrever(pedaco.data, pedaco.position),
    });
    const formato = mp4 ? new mb.Mp4OutputFormat({ fastStart: false }) : new mb.WebMOutputFormat();
    const saida = new mb.Output({ format: formato, target: new mb.StreamTarget(escrita, { chunked: true, chunkSize: 2 * 1024 * 1024 }) });
    /* "realtime": o codificador prefere soltar quadro a atrasar; o quadro só entra quando o anterior foi aceito. */
    const fonteDoVideo = new mb.CanvasSource(canvas, { codec: codecDeVideo, bitrate: 1_500_000, keyFrameInterval: 2, latencyMode: "realtime" });
    saida.addVideoTrack(fonteDoVideo, { frameRate: QUADROS });
    this.fonteDoVideo = fonteDoVideo;
    saida.addAudioTrack(new mb.MediaStreamAudioTrackSource(audio as MediaStreamAudioTrack, { codec: codecDeAudio, bitrate: 128_000 }));
    await saida.start();
    this.saida = saida;
    this.comecou = Date.now();
    this.ativo = true;
    this.ligarRelogio();
  }

  /*
   * O relógio dos quadros mora num "worker": com a aba da sala em segundo
   * plano (quem apresenta foi para a aba que está mostrando), o navegador
   * segura os relógios da página em 1 por segundo e a gravação saía quase
   * parada. O do worker não é segurado. Sem worker, fica o da página.
   */
  private ligarRelogio() {
    const intervalo = Math.round(1000 / QUADROS);
    try {
      const codigo = "let t=null;onmessage=(e)=>{clearInterval(t);if(e.data>0)t=setInterval(()=>postMessage(0),e.data)}";
      const endereco = URL.createObjectURL(new Blob([codigo], { type: "text/javascript" }));
      const trabalhador = new Worker(endereco);
      URL.revokeObjectURL(endereco);
      trabalhador.onmessage = () => this.quadro();
      trabalhador.postMessage(intervalo);
      this.trabalhador = trabalhador;
    } catch {
      this.relogio = setInterval(() => this.quadro(), intervalo);
    }
  }

  private desligarRelogio() {
    if (this.relogio) clearInterval(this.relogio);
    this.relogio = null;
    this.trabalhador?.terminate();
    this.trabalhador = null;
  }

  /* Um quadro: desenha e entrega ao codificador, ou pula se ele ainda está com o anterior. */
  private quadro() {
    if (!this.ativo || !this.fonteDoVideo) return;
    if (this.codificando) {
      this.pulados += 1;
      return;
    }
    this.desenhar();
    this.codificando = true;
    const instante = (Date.now() - this.comecou) / 1000;
    this.fonteDoVideo
      .add(instante, 1 / QUADROS)
      .catch(() => {
        this.pulados += 1;
      })
      .finally(() => {
        this.codificando = false;
      });
  }

  get segundos(): number {
    return this.ativo ? Math.round((Date.now() - this.comecou) / 1000) : 0;
  }

  get bytes(): number {
    return this.envio?.bytes ?? 0;
  }

  /* Fecha o arquivo e manda o resto. */
  /* `deVez`: quem organiza parou a gravação de propósito (recarregar a página não recomeça). */
  async terminar(deVez = false): Promise<void> {
    if (!this.ativo || !this.saida || !this.envio) return;
    this.ativo = false;
    this.desligarRelogio();
    const segundos = Math.round((Date.now() - this.comecou) / 1000);
    try {
      /* Fechar o arquivo é rápido (não há fila de quadros); se travar mesmo assim, desiste em 1 minuto. */
      const saida = this.saida;
      await Promise.race([
        saida.finalize(),
        new Promise<never>((_, falhar) => setTimeout(() => falhar(new Error("a gravação não fechou a tempo")), 60000)),
      ]);
      await this.envio.terminar();
    } catch (erro) {
      /* O arquivo não ficou inteiro aqui: avisa o servidor, para a sala não ficar "gravando" para sempre. */
      await this.saida?.cancel().catch(() => undefined);
      await this.api.pedir("gravacao", {}, { consulta: this.consulta("cancelar") }).catch(() => undefined);
      this.limpar();
      throw erro;
    }
    try {
      /*
       * Todas as partes subiram: daqui em diante NUNCA cancela (cancelar apaga
       * as partes no depósito; foi assim que a live de 17/10 se perdeu). Se
       * fechar falhar, o servidor guarda tudo para tentar de novo ou recuperar.
       */
      await this.fecharNoServidor(segundos, deVez);
    } finally {
      this.limpar();
    }
  }

  private async fecharNoServidor(segundos: number, deVez: boolean): Promise<void> {
    for (let tentativa = 1; ; tentativa += 1) {
      try {
        await this.api.pedir("gravacao", { segundos, deVez }, { consulta: this.consulta("fechar"), prazoMs: 90000 });
        return;
      } catch (erro) {
        if (erroDefinitivo(erro) || tentativa >= 3) throw erro;
        await new Promise((r) => setTimeout(r, 3000 * tentativa));
      }
    }
  }

  async cancelar(): Promise<void> {
    this.ativo = false;
    await this.saida?.cancel().catch(() => undefined);
    await this.api.pedir("gravacao", {}, { consulta: this.consulta("cancelar") }).catch(() => undefined);
    this.limpar();
  }

  private limpar() {
    this.desligarRelogio();
    this.soltarVideos(new Set());
    this.caixa?.remove();
    this.caixa = null;
    for (const no of this.entradas.values()) no.disconnect();
    this.entradas.clear();
    void this.audio?.close().catch(() => undefined);
    this.audio = null;
    this.destino = null;
    this.saida = null;
  }
}
