Host Bridge
This page describes the plugin’s bridge, used from the in-player Watch Party panel. Since plugin 2.1 it is off unless an admin turns on Let users bridge their devices from the Watch Party panel (meant for small servers with trusted users), and users can only bridge their own sessions. The normal way is the session server’s admin panel: it drives devices over the Jellyfin API, can put any device into any room as host or receiver, and starts playback on receivers itself.
Overview
Normally, hosting a watch party requires running the injected web client — the browser is what talks to the session server. That’s a problem for native/TV clients that can’t run injected JavaScript at all (e.g. Fladder on Android TV, Swiftfin, Infuse, official mobile/TV apps). Host Bridge solves this on the host side only: any logged-in user with browser access to the same Jellyfin server can bridge one of their own currently-playing native sessions in as a room host (administrators: anyone’s). Guests are completely unaffected — they still join the resulting room from their own room list exactly as they would any other room, and the room is indistinguishable from a browser-hosted one to them.
The bridge also supports the opposite direction — a receiver: a native session can be attached to a room this browser is already in and kept in sync with the host, driven by Jellyfin’s generic remote-control playstate commands (which official clients such as the Android TV app honour: pause / unpause / absolute seek). This is how an official client that can’t run the injected UI can still follow a party. The session must already be playing the room’s item; the receiver keeps play/pause and position aligned but does not start playback remotely.
Both directions are opt-in and off by default, behind a master
switch, PluginConfiguration.EnablePanelBridging. With it on, the host
role is gated by AllowThirdPartyClientHost and the receiver role by
AllowSupportedClientReceiver (admins toggle these in the plugin config
page’s Watch Party Panel Bridging section; PanelHostAllowed /
PanelReceiverAllowed combine them). The combined values ride along on
the /JellyWatchParty/Token response (allow_third_party_host /
allow_supported_receiver) so the injected client can hide the matching
picker, and are enforced server-side: Bridge/{sessionId}/Start rejects
when hosting is disabled, Bridge/{sessionId}/Follow when the receiver
role is disabled, and Bridge/Sessions returns an empty list when
neither is enabled. Turning the switch (or a role) off stops the
matching running bridges (HostBridgeManager.ApplyConfigurationAsync,
on Plugin.ConfigurationChanged).
Ownership: Sessions, Status, Start, Follow and Stop only cover the
caller’s own sessions (session UserId == the caller’s
Jellyfin-UserId claim). Callers in the Administrator role may
bridge and stop any session. Before 2.1 any user could bridge or stop
anyone’s session, and attach any session to any room as a receiver,
bypassing Jellyfin’s EnableRemoteControlOfOtherUsers permission.
Both bridges send the bridged session’s DeviceId as bridge_device_id
in create_room / join_room. The session server records it and shows
the connection as a Plugin bridge in its admin panel. One device is
never driven from two places: the admin panel refuses a device a plugin
bridge drives, and the server refuses a plugin bridge’s create/join
(reason: "device_already_bridged") for a device that is already in a
room. Start waits up to 5 s for the server’s answer, so such a refusal
(or a wrong room password) reaches the user as an error instead of a
bridge without a room.
Stopping a bridge sends leave_room before closing, so it leaves its
room at once instead of after the server’s 90 s reconnect grace. A
bridge whose room closes, or that an admin removes, stops itself.
So a native client can still participate as a guest via the receiver
role. Running the injected UI directly (a browser, or Jellyfin Desktop
via its native player adapter — see
Client: utils/video.js) remains the
way to join and drive a room from the client itself.
How It Works
Native session (e.g. Fladder on Android TV)
│ (Jellyfin server-side playback events only —
│ the native client itself does nothing JWP-specific)
▼
ISessionManager (Jellyfin server)
│ PlaybackStart / PlaybackProgress / PlaybackStopped
▼
HostBridgeManager (hosted service, subscribed for the plugin's lifetime)
│ routes events to the matching SessionHostBridge, if any
▼
SessionHostBridge (one per bridged session)
│ owns a ClientWebSocket to the session server
│ speaks the normal client protocol: auth → create_room →
│ player_event / state_update
▼
Session Server (Rust) — room created, this session is the host
▲
│ guests join normally, from their own room list
Browser guests (unaffected, unaware a bridge is involved)
Components
Services/HostBridgeManager.cs
A hosted service (IHostedService) that subscribes to Jellyfin’s
ISessionManager.PlaybackStart/PlaybackProgress/PlaybackStopped
events for the plugin’s entire lifetime and owns all currently-active
SessionHostBridge instances, keyed by Jellyfin session ID.
Eligibility filter (GetEligibleSessions()): only sessions that are
currently playing something, aren’t already bridged, and whose Client
does not start with "Jellyfin Web", "Jellyfin Desktop", or
"Jellyfin Media Player" (prefix match, since Client includes a
trailing version — e.g. "Jellyfin Web 10.11.11",
"Jellyfin Desktop 3.0.0-dev"). Those three already run the injected
script and can host normally via “Create Room”, so they’re excluded
from the bridge picker to avoid clutter.
StartBridgeAsync(sessionId) creates a SessionHostBridge for the
session and starts it; StopBridgeAsync(sessionId) tears one down.
Playback events for an already-bridged session are routed to that
bridge’s OnPlaybackProgressAsync; a PlaybackStopped event removes
and disposes the bridge automatically.
Services/SessionHostBridge.cs
One instance per bridged session. Owns a ClientWebSocket connection to
the session server and translates Jellyfin session events into the
exact same protocol messages a browser host would send
(see Protocol):
| Jellyfin event | → | Protocol message |
|---|---|---|
| Bridge start | → | auth (JWT if configured, else user_id/user_name), then create_room (with start_pos and media_id from the session’s current playback) |
| Play/pause state changes | → | player_event (action: "play" or "pause") |
| Position updates (no play-state change) | → | state_update |
It tracks its own RoomId by watching for the room_state message the
server sends back after create_room, and clears it on room_closed.
Known limitation: doesn’t send set_media. The web host detects an
item change and sends set_media (see protocol.md, issue #71); a
bridged native host does not, since HostBridgeManager disposes the
bridge on PlaybackStopped, which fires on every item change —
including switches within the same bridged session, not just the end of
it. A native host that starts a different item currently just ends the
bridge rather than updating the room’s media. Tracked as a follow-up.
Services/SessionFollowerBridge.cs
One instance per receiver session — the receive-only counterpart to
SessionHostBridge. Owns a ClientWebSocket that auths and joins
an existing room (rather than creating one), then consumes the host’s
broadcast messages and translates them into remote-control commands:
| Room message | → | Remote-control command |
|---|---|---|
room_state (on join) / state_update — play_state + position |
→ | initial/ongoing Pause/Unpause and drift-correcting Seek |
player_event — action: "play"/"pause" + position |
→ | Unpause/Pause, plus Seek on large drift |
Commands are sent via
ISessionManager.SendPlaystateCommand(controllingSessionId: "", sessionId, …)
— an empty controlling-session id skips Jellyfin’s control-permission
path and relays the command straight to the target session’s socket.
Play/pause is only re-sent on a state change, and Seek only fires when
the session has drifted past a threshold (~2 s) and no seek has been
issued within a short cooldown, so drift correction doesn’t spam the
client. HostBridgeManager owns follower bridges alongside host bridges
(a session is one role or the other) and disposes a follower when its
session stops playing.
RoomId is set only when the server confirms the join with a room_state
message — not optimistically at connect time — so a rejected join does not
show up as a phantom connected bridge. On that same room_state the follower
sends a ready message: a headless bridge has no video to buffer, and the
server’s play gate (all_ready / pending_play) would otherwise hold back
every host play for the whole room until it timed out waiting for the
follower to ready. ready persists for the room’s lifetime, so it is sent
once, on join.
Known limitations (receiver):
- Does not follow
media_changed.ParseRoomEventonly understandsplayer_event,state_updateandroom_state.state; amedia_changedmessage (room switched to a different item) is ignored. Since the follower is also disposed on its own session’sPlaybackStopped, an item switch just ends the bridge rather than following it. - Password-protected rooms are not supported. The follower’s
join_roomdoes not carry the room password (the web client doesn’t retain it), so the server rejects the join; the receiver simply never syncs. - No reconnect. Like
SessionHostBridge, a follower does not re-establish its WebSocket after a drop — it must be re-attached. It also does not tear itself down onroom_closed(it just stops following); use Stop, or it is cleaned up when the session stops playing.
Services/SessionServerAuth.cs
Shared JWT-minting logic used by both the bridge (minting a token for
the bridged session’s owner, not the HTTP caller) and the normal
/JellyWatchParty/Token endpoint.
REST Endpoints
See Plugin: REST API Reference for the
full endpoint table (Bridge/Sessions, Bridge/Status,
Bridge/{sessionId}/Start, Bridge/{sessionId}/Stop,
Bridge/{sessionId}/Follow?roomId=…) and auth gating. Follow attaches
the session to the given room as a receiver (the room-id comes from the
room the calling browser is currently in).
Bridge/Sessions returns sessionId, userName, deviceName, client,
nowPlayingItemName per eligible session; Bridge/Status and the
Start/Follow/Stop responses return sessionId, userName, roomId,
connected, role ("host" or "receiver"). Response fields are camelCase — Jellyfin’s controllers don’t
auto-camelCase JSON output, so the controller projects onto anonymous
objects with the exact keys the UI expects, matching the existing
/Token endpoint’s user_id/auth_enabled convention of spelling
field names out explicitly rather than relying on a naming policy.
UI
src/clients/jellyfin-web/ui/bridge.js renders a “Host From Another
Device” section directly inside the normal lobby panel
(ui/render.js’s renderLobby) — not the Jellyfin admin config page.
It lists eligible sessions and active bridges (polling the two GET
endpoints), and is rendered in both panel contexts: the pre-room
lobby (renderLobby) and the in-room view (renderRoom). Each eligible
session shows a single action matching the context: Host in the
lobby (start a new room from that session) and Receiver while in a
room (attach that session to the current room, passing state.roomId
to Follow). Active bridges show their role and can be stopped with a
button. All calls use the same ApiClient.accessToken() pattern the
rest of the client uses to call plugin endpoints.
Related
- Features: Known Limitations — how this changes the “web only” caveat
- Troubleshooting & FAQ: Can I host from Fladder or another Android TV app?
- User Guide: Watching on a TV App