Project Overview

Jibri (Jitsi BRoadcasting Infrastructure) records and live-streams Jitsi Meet conferences by launching a Chrome instance on a virtual display that joins the conference as a participant, then captures the media with FFmpeg.

Jibri’s pre-existing approach reads conference state by visiting the Jitsi Meet page and reaching into its internals (querying APP.conference and APP.store). This project adds an alternative: driving Jibri through Jitsi Meet’s External API instead, the same event-driven, iframe-based interface third-party apps use to embed Jitsi Meet. Both modes are supported today, toggled by a feature flag.

Mentors: Damyan Minkov (damencho), Jaya Allamsetty

Two Recording Modes

Two recording modes toggled by a feature flag (jibri.selenium.use-external-api, default false) in jibri.conf:

  1. Jibri launches Chrome using Selenium after receiving an XMPP recording signal, joining as a recorder (iAmRecorder=true)

  2. Chrome joins the call and Jibri reads state and sends actions, described below per mode:

    AppCallPage (pre-existing, internal state-based)

    AppCallPage flow: Jibri navigates to the Meet page and reads APP.conference/APP.store

    • Chrome navigates to the room
    • Reads conference state through APP.conference/APP.store, using sync executeScript() polling, plus injected event listeners
    • Sends actions (e.g. toggling mute) using async executeAsyncScript(), waiting for the Redux store to confirm the change

    ExternalAPIPage (new, iframe-based)

    ExternalAPIPage flow: Jibri loads recorder.html via file://, which embeds Jitsi Meet in an iframe through JitsiMeetExternalAPI

    • recorder.html, bundled as a resource in the Jibri JAR, is extracted to a local temp file and loaded via file://, with room/baseUrl/tenant/config passed as URL query parameters
    • recorder.html loads external_api.js from the Jitsi Meet deployment’s baseUrl, creates a JitsiMeetExternalAPI instance, and embeds the conference in an iframe
    • Jibri reads state and sends actions by calling into window.jibriRecorderApi, the JitsiMeetExternalAPI instance itself, either synchronously using executeScript() or asynchronously via executeAsyncScript() depending on the call. For example, getNumParticipants() reads jibriRecorderApi.getRoomsInfo(), and toggleAudioMute() sends jibriRecorderApi.executeCommand('toggleAudio')
  3. FFmpeg captures video and audio into MP4 (recording) or FLV over RTMP (streaming)

Implementation

Today, in ExternalAPIPage mode, Jibri can: join a conference, read its state, send commands like muting or raising a hand, and detect empty calls to stop recording automatically, matching everything AppCallPage already does.

Shared CallPage interface. Both AppCallPage and ExternalAPIPage implement a common CallPage interface, so the rest of Jibri only depends on CallPage’s methods, never on which implementation is actually running. A factory function, CallPage.create(), picks the implementation at runtime based on the feature flag.

Feature parity. ExternalAPIPage tracks the same conference state as AppCallPage (participant counts, mute state, force-mute state, ICE connection state, Jigasi participant detection, hidden/visitor/kick detection, hand-raise, and presence properties) and implements the same actions.

recorder.html. A static page bundled inside the Jibri JAR that creates a JitsiMeetExternalAPI instance, embeds the conference in an iframe, and exposes the instance as window.jibriRecorderApi for Jibri’s WebDriver calls to read state and send commands.

Code Contributions

20 PRs shipped across jibri, jitsi-meet, and lib-jitsi-meet, documented in the Jitsi handbook. All merged.

jibri

DescriptionPR
Load the recorder page (visit(), initial page setup)#609
Detect participant count and empty calls (getNumParticipants() / isCallEmpty())#610
Expose per-participant bitrate stats (getBitrates())#612
Hide recorder from participant list#613
Fix recorder visibility (localStorage credentials)#614
Detect ICE connection state (isIceConnected())#615
Count Jigasi (SIP bridge) participants (numRemoteParticipantsJigasi())#616
Recorder hangup (leave())#617
Send/update recorder presence and participant properties (addToPresence() / sendPresence() / setParticipantProperties())#618
Detect local audio/video mute state (isLocalAudioMuted() / isLocalVideoMuted())#620
Toggle local audio/video mute (toggleVideoMute() / toggleAudioMute())#621
Count muted remote participants (numRemoteParticipantsMuted())#622
Detect if the recorder was kicked (isLocalParticipantKicked())#623
Detect force-mute state (isAudioForceMuted() / isVideoForceMuted())#624
Raise hand, recorder signaling (raiseHand())#627
Unmute the recorder (unmute())#626
Count hidden participants (numHiddenParticipants())#628
Detect visitor role (isVisitor())#629
Fetch participant identities for recording metadata (getParticipants())#630
Fix local recording video on Wayland (black screen)#632

jitsi-meet

DescriptionPR
Expose per-participant bitrate stats (getBitrates())#17594
Expose ICE connection state (isIceConnected())#17620
Expose Jigasi (SIP bridge) participant flag (numRemoteParticipantsJigasi())#17624
Support recorder presence and participant properties (addToPresence() / sendPresence() / setParticipantProperties())#17639 + lib-jitsi-meet #3071
Expose muted-participant count (numRemoteParticipantsMuted())#17639

Jitsi handbook

DescriptionPR
Document getBitrates()#651
Document isIceConnected()#657
Document numRemoteParticipantsJigasi()#658
Document presence properties (addToPresence() / sendPresence() / setParticipantProperties())#673
Document numRemoteParticipantsMuted()#675
Document numHiddenParticipants()#678

Challenges & Learnings

Serving recorder.html without a web server. The initial plan was to serve recorder.html through a REST API in Jibri, but damencho suggested keeping it as a static file instead, extracted from the Jibri JAR and loaded via file://, no web server needed. Issue: recorder.html still needs to load external_api.js from the Jitsi Meet deployment, and a page loaded from file:// has no origin. The patch: pass the deployment’s baseUrl as a query parameter and load external_api.js from it through a <script> tag, which works regardless of recorder.html’s file:// origin.

Getting credentials into the iframe. recorder.html needs Jibri’s login credentials to join as an authenticated participant. With AppCallPage, Jibri passes credentials as a URL query parameter (appData.localStorageContent) when navigating to the Jitsi Meet page; Jitsi Meet’s client code parses it and writes the values into localStorage. recorder.html can’t rely on that: JitsiMeetExternalAPI builds the iframe’s URL internally, reading only from the host page’s localStorage, not its URL query params. The fix: recorder.html reads its appData.localStorageContent param and writes it to localStorage["jitsiLocalStorage"], which JitsiMeetExternalAPI reads from the host, before constructing the API. This took a second pass, fixing a double-JSON.stringify bug.

Local recording on Wayland. Local recordings using Wayland produced blank, empty frames, for two reasons: Chrome’s DISPLAY was hardcoded to :0 while FFmpeg captured Xvfb’s :1, and Wayland’s auto-detection was bypassing X11 rendering. I fixed both: made JibriSelenium.kt read the display from config, and forced --ozone-platform=x11 to stop Chrome falling back to Wayland.

A split local/docker setup. I started with a Docker setup, but switched to running Jibri and Jitsi Meet on the host for faster iteration. This project also extended jitsi-meet’s External API, and the Docker deployment ran a build without those changes, so the setup split in two: Jibri, Jitsi Meet’s web frontend, and a virtual display ran locally on the host, with the rest of the Jitsi stack (JVB, Jicofo, Prosody) running in Docker. This meant juggling separate logs (Chrome/Selenium, FFmpeg, Jibri).

Future Work

  • Add end-to-end tests with Playwright

Acknowledgements

Thank you to Damyan Minkov (damencho) and Jaya Allamsetty for their mentorship, and the Jitsi community for their support along the way. Damencho’s involvement through interaction and review was key to the project’s progress.