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:
Jibri launches Chrome using Selenium after receiving an XMPP recording signal, joining as a recorder (
iAmRecorder=true)Chrome joins the call and Jibri reads state and sends actions, described below per mode:
AppCallPage (pre-existing, internal state-based)

- Chrome navigates to the room
- Reads conference state through
APP.conference/APP.store, using syncexecuteScript()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)

recorder.html, bundled as a resource in the Jibri JAR, is extracted to a local temp file and loaded viafile://, with room/baseUrl/tenant/config passed as URL query parametersrecorder.htmlloadsexternal_api.jsfrom the Jitsi Meet deployment’sbaseUrl, creates aJitsiMeetExternalAPIinstance, and embeds the conference in an iframe- Jibri reads state and sends actions by calling into
window.jibriRecorderApi, theJitsiMeetExternalAPIinstance itself, either synchronously usingexecuteScript()or asynchronously viaexecuteAsyncScript()depending on the call. For example,getNumParticipants()readsjibriRecorderApi.getRoomsInfo(), andtoggleAudioMute()sendsjibriRecorderApi.executeCommand('toggleAudio')
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
| Description | PR |
|---|---|
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
| Description | PR |
|---|---|
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
| Description | PR |
|---|---|
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.