Live (LiveKit rooms): moving from the room page to the call widget unsubscribes you from everyone's video (black tiles) - w/ proposed fix

AI-generated summary

sperry, a first-time contributor setting up a Discourse forum for an automotive group, reports a bug in the Live (LiveKit) integration where video tiles turn black when navigating from a room page to the floating call widget. The issue occurs specifically in LiveKit (SFU) rooms: after moving to the widget, the viewer stops receiving video from other participants, while those participants can still see the viewer. Rejoining or navigating back to the room page temporarily restores the video.

The root cause is a race condition in the watching state updates. When leaving the room page, the teardown logic sends a watching=false update. However, the call widget simultaneously sends a watching=true update. If the room page’s false update is processed after the widget’s true update, the SFU unsubscribes the viewer’s video tracks, resulting in black tiles. This does not happen in peer-to-peer rooms or when switching from widget to page because of the timing of synchronous versus deferred calls.

Evidence includes the viewer’s video element showing muted: true and totalVideoFrames: 0 despite having a live stream, and network logs showing watching states updating in the order true, true, false. A confirmed workaround is manually calling setWatching(roomId, true) via the console. sperry provided a temporary JS wrapper that delays false updates to ensure the widget’s state takes precedence.

The suggested permanent fix is to derive the watching state from whether either the room page or the call widget is mounted, rather than letting each component independently send conflicting set/clear updates. This would prevent ordering issues. Additionally, the report notes that failed POST /live/rooms/<id>/state requests (e.g., 429 errors) are silently caught, which could exacerbate state desyncs. sperry has offered to submit a pull request with a proposed fix.

Hi all, first time contributor, so sorry if I’m not formatting this the proper way.

I’m setting up a Discourse forum for an automotive enthusiast group and they wanted to use Live video chats via LiveKit for larger rooms/presentations.

During testing we consistently ran into black tiles for video streams, but only when using the LiveKit integration. I used Claude Code to investigate and found what looks like the issue and a fix, the Claude generated report follows.

I’m happy to make a PR with a proposed fix if that would be helpful.

Summary

In a LiveKit (SFU) room, going from the room page to the floating call widget leaves the viewer with black tiles for everyone else. The other participants still see the viewer fine. Going back to the room page, or leaving and rejoining while in the widget, brings the video back. It happens every time, because the room page’s teardown sends watching=false after the widget has sent watching=true.

Version

  • Discourse at commit 1f09a33bffd908d6e38ef4b51697e8390e2a4c7f (the latest branch, 2026-10), Live plugin as bundled
  • LiveKit Cloud as the media server (live_livekit_room_policy = per_room)
  • Firefox 159 on Linux (also observed on other browsers and phones)

Steps

  1. Two accounts (one in a private window) join the same LiveKit-enabled room with cameras on.
  2. In one window, click a link that leaves the room page, so the call moves to the widget.
  3. In that window the other participant’s tile is black. The other window sees both fine.

What I saw

  • In the black window, the other person’s <video> has a stream, is not paused and has readyState 4. But its track is readyState: "live", muted: true, 8×8, and getVideoPlaybackQuality().totalVideoFrames is 0. No media ever arrived on it.
  • Network tab, one page → widget switch: three POST /live/rooms/<id>/state calls, with watching = true, true, false in that order.

Cause (from reading the code at the commit above)

  • room-page.gjs, willDestroy: next(() => liveWebrtc.setWatching(roomId, false, { keepVideo }))
  • call-widget.gjs: the widget’s didInsert calls watchWidgetRoom, which runs next(() => setWatching(roomId, true))
  • live-webrtc.js, setWatching: on non-mesh rooms it calls setVideoSubscriptionsEnabled(watching), which unsubscribes the viewer’s camera, screen and screen-audio tracks (livekit-session.js, #applyDesiredSubscription)

The page’s deferred false lands after the widget’s true, so the viewer ends up in the not-watching state while the widget is on screen. Going widget → room page works because the widget’s stopWatchingWidgetRoom runs synchronously on destroy and the page’s true is deferred, so true wins. A fresh join in the widget works because there is no old page.

Peer-to-peer rooms don’t unsubscribe anyone (the SFU branch is skipped), which would match this being specific to LiveKit rooms.

Confirmed workaround, in the browser console

  1. With the tile black, Discourse.__container__.lookup("service:live-webrtc").setWatching(1, true) brought the video back.
  2. This wrapper, installed before switching, kept the tile up on every page → widget switch:
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);
};

Suggested fix

The room page’s teardown shouldn’t turn watching off when the widget is about to show the same room. For example, derive the watching state from whether the page or the widget is mounted, instead of letting each send its own set/clear. That way ordering can’t matter.

Related, may be separate

One POST .../state in the same session was answered with 429. Failures on these calls are ignored (.catch(() => {})), so a refused “watching” update fails silently.