Live (salas LiveKit): mover da página da sala para o widget de chamada desinscreve você do vídeo de todos (tiles pretos) - com correção proposta

Olá a todos, sou um contribuinte de primeira viagem, então peço desculpas se não estou formatando isso da maneira adequada.

Estou configurando um fórum Discourse para um grupo de entusiastas de automóveis e eles queriam usar videochamadas ao vivo via LiveKit para salas mais amplas/apresentações.

Durante os testes, encontramos consistentemente blocos pretos nos streams de vídeo, mas apenas ao usar a integração com o LiveKit. Usei o Claude Code para investigar e encontrei o que parece ser o problema e uma solução. O relatório gerado pelo Claude segue abaixo.

Ficarei feliz em criar um PR com a correção proposta, se isso for útil.

Resumo

Em uma sala LiveKit (SFU), ao navegar da página da sala para o widget de chamada flutuante, o espectador fica com blocos pretos para todos os outros participantes. Os demais participantes ainda conseguem ver o espectador normalmente. Voltar para a página da sala, ou sair e entrar novamente enquanto estiver no widget, restaura o vídeo. Isso acontece sempre, porque o desmonte da página da sala envia watching=false depois que o widget já enviou watching=true.

Versão

  • Discourse no commit 1f09a33bffd908d6e38ef4b51697e8390e2a4c7f (branch latest, outubro de 2026), plugin Live conforme empacotado
  • LiveKit Cloud como servidor de mídia (live_livekit_room_policy = per_room)
  • Firefox 159 no Linux (também observado em outros navegadores e celulares)

Passos

  1. Duas contas (uma em uma janela anônima) entram na mesma sala habilitada para LiveKit com as câmeras ligadas.
  2. Em uma das janelas, clique em um link que sai da página da sala, fazendo a chamada migrar para o widget.
  3. Nessa janela, o bloco do outro participante fica preto. A outra janela vê ambos normalmente.

O que observei

  • Na janela com a tela preta, a tag <video> da outra pessoa possui um stream, não está pausada e tem readyState 4. Mas sua track está com readyState: "live", muted: true, resolução 8×8 e getVideoPlaybackQuality().totalVideoFrames é 0. Nenhum mídia chegou a ela.
  • Na aba de rede, durante a troca de uma página para o widget: três chamadas POST /live/rooms/<id>/state, com watching = true, true, false nessa ordem.

Causa (a partir da leitura do código no commit acima)

  • room-page.gjs, willDestroy: next(() => liveWebrtc.setWatching(roomId, false, { keepVideo }))
  • call-widget.gjs: o didInsert do widget chama watchWidgetRoom, que executa next(() => setWatching(roomId, true))
  • live-webrtc.js, setWatching: em salas não-mesh, chama setVideoSubscriptionsEnabled(watching), o que cancela a inscrição nas tracks de câmera, tela e áudio da tela do espectador (livekit-session.js, #applyDesiredSubscription)

O false adiado da página chega depois do true do widget, fazendo com que o espectador acabe no estado “não assistindo” enquanto o widget está na tela. A transição do widget para a página da sala funciona porque o stopWatchingWidgetRoom do widget é executado sincronamente na destruição e o true da página é adiado, então o true vence. Uma nova entrada no widget funciona porque não há uma página antiga.

Salas peer-to-peer não cancelam inscrições de ninguém (o ramo SFU é ignorado), o que estaria de acordo com o problema ser específico para salas LiveKit.

Workaround confirmado, no console do navegador

  1. Com o bloco preto, Discourse.__container__.lookup("service:live-webrtc").setWatching(1, true) restaurou o vídeo.
  2. Este wrapper, instalado antes da troca, manteve o bloco ativo em todas as transições página → widget:
const svc = Discourse.__container__.lookup("service:live-webrtc");
const orig = svc.setWatching.bind(svc);
svc.setWatching = (roomId, watching, options = {}) => {
  if (watching) return orig(roomId, true, options);
  setTimeout(() => {
    const shown = document.querySelector(".live-call-widget, .live-room-page");
    orig(roomId, !!shown, options);
  }, 500);
};

Sugestão de correção

O desmonte da página da sala não deve desativar o status “assistindo” quando o widget está prestes a mostrar a mesma sala. Por exemplo, derivar o estado “assistindo” de whether a página ou o widget está montado, em vez de deixar cada um enviar seu próprio conjunto/limpeza. Dessa forma, a ordem não importa.

Relacionado, pode ser separado

Um POST .../state na mesma sessão foi respondido com 429. Falhas nessas chamadas são ignoradas (.catch(() => {})), então uma recusa da atualização “watching” falha silenciosamente.