JavaScript Client
Overview
The JellyWatchParty client is a set of JavaScript modules (IIFE pattern) injected into Jellyfin’s web interface. These modules handle playback synchronization between multiple users via WebSocket.
Module Architecture
plugin.js # Loader - loads modules in parallel waves
├── state.js # Global state and constants
├── utils/ # Utility functions
│ ├── log.js, media.js, misc.js, time.js, video.js
├── ui/ # User interface
│ ├── cards.js, home.js, indicators.js
│ ├── render.js, styles.js, toasts.js
├── playback/ # Video playback management
│ ├── bind.js, play.js, sync.js, tracks.js
├── chat/ # Text chat
│ ├── input.js, messages.js
├── ws/ # WebSocket communication
│ ├── send.js, auth.js, connection.js
│ └── handlers/
│ ├── clock.js, playback.js, room.js, sync.js
└── app/ # Initialization and cleanup
├── cleanup.js, lifecycle.js
Modules are loaded in dependency waves, parallelizing where possible:
state.js → utils/* → ui/* → playback/* → chat/* → ws/send → ws/auth → ws/handlers/* → ws/connection → app/*
Module: state.js
Description
Defines global shared state and configuration constants. Guarded by
if (JWP.state) return; so re-injection (e.g. after a hot reload) is a
no-op.
Constants (JWP.constants)
| Constant | Value | Description |
|---|---|---|
SUPPRESS_MS |
2000 |
Event suppression / anti-feedback lock duration (ms) |
SEEK_THRESHOLD |
1.0 |
Position difference to trigger seek (s) |
STATE_UPDATE_MS |
1000 |
State update send interval (ms) |
SYNC_LEAD_MS |
300 |
Latency compensation advance (ms) |
DRIFT_CORRECTION_ENTER_SEC |
0.3 |
Drift needed to start a correction burst (s) |
DRIFT_CORRECTION_EXIT_SEC |
0.1 |
Drift must fall under this to stop correcting (s) |
DRIFT_SOFT_MAX_SEC |
2.0 |
Forced-seek threshold (s) |
PLAYBACK_RATE_MIN / MAX |
0.85 / 2.0 |
Catchup playback-rate clamp |
DRIFT_GAIN |
0.50 |
Sqrt-curve proportional gain |
UI_CHECK_MS |
2000 |
UI/video-binding check interval (ms) |
PING_INIT_MS |
2000 |
Ping interval before clock sync stabilizes (ms) |
PING_STABLE_MS |
30000 |
Ping interval after stabilizing (ms) |
PING_STABLE_AFTER |
5 |
Successful pongs before switching to the stable interval |
HOME_REFRESH_MS |
5000 |
Home watch-parties refresh interval (ms) |
SYNC_LOOP_MS |
500 |
Drift-correction loop interval (ms) |
RECONNECT_BASE_MS / MAX_MS |
1000 / 30000 |
Exponential-backoff reconnect delay bounds |
INITIAL_SYNC_COOLDOWN_MS |
8000 |
Cooldown after join before allowing a hard seek |
INITIAL_SYNC_MAX_MS |
30000 |
Max duration of the initial-sync phase |
INITIAL_SYNC_DRIFT_THRESHOLD |
0.5 |
Drift under which initial sync ends early (s) |
INITIAL_SYNC_MAX_DRIFT |
10 |
Drift that forces a hard seek during initial sync (s) |
TIME_SYNC_MAX_SAMPLES |
8 |
Clock-sync sliding-window sample count |
TIME_SYNC_EMA_ALPHA |
0.4 |
Clock-sync EMA smoothing coefficient |
See also PANEL_ID, BTN_ID, STYLE_ID, HOME_SECTION_ID (DOM element
IDs) and DEFAULT_WS_URL (ws(s)://<host>:3000/ws, scheme matching the
page’s protocol).
State (JWP.state)
Notable fields beyond the obvious (ws, roomId, clientId, isHost,
inRoom, rooms): serverOffsetMs/hasTimeSync/timeSyncSamples
(clock sync), lastSyncServerTs/lastSyncPosition/lastSyncPlayState
(drift-correction baseline), isDriftCorrecting (hysteresis latch —
true while a correction burst is active, see playback/sync.js),
isInitialSync/initialSyncUntil/initialSyncTargetPos (the
post-join catch-up phase, which suppresses hard seeks for
INITIAL_SYNC_COOLDOWN_MS), syncStatus ('unknown'/'synced'/
'syncing'/'pending_play', drives the UI indicator),
reconnectAttempts (exponential backoff counter),
intervals: { ui, ping, home, sync, stateUpdate } (handles for
clearInterval, tracked to avoid leaks), authToken/authEnabled/
tokenExpiresAt/tokenRefreshTimer (JWT lifecycle), and
homeRoomCache (an LRUCache of 50 entries for home-page cover images).
Module: utils/ — Shared Helpers
utils/time.js
nowMs(), getServerNow() (nowMs() + serverOffsetMs),
adjustedPosition(position, serverTs) (adds elapsed time since
serverTs plus SYNC_LEAD_MS), scheduleAt(serverTs, fn) (runs fn
immediately if the target time has passed, otherwise setTimeouts the
remainder — also clears any prior pending action timer), and
startSyncing() (sets the isSyncing anti-feedback lock for
SUPPRESS_MS).
utils/misc.js
shouldSend() (outside the suppression window), suppress(ms?),
escapeHtml(str) (chat/room-name XSS escaping), getItemImageUrl(itemId,
imageTag), isHomeView().
utils/media.js
getCurrentItem()/getCurrentItemId() — locates the currently playing
item by checking, in order: the Jellyfin playback manager, window
globals (NowPlayingItem, Emby.Page.currentRouteInfo,
sessionStorage.playbackInfo), a DOM data-attribute lookup, and finally
the URL hash. This layered fallback exists because none of those sources
is reliably present across all Jellyfin skins/versions.
utils/log.js
log(category, data) — formats structured log lines ([JWP:CATEGORY]
key=val ..., with unit-aware formatting for position/rate/offset
fields), logs to the console, and also relays them to the server as a
client_log message when connected (buffering up to logBufferMax
entries — default 100 — while disconnected, flushed via
flushLogBuffer() once the socket reopens).
utils/video.js
getVideo(), isVideoReady(), isBuffering(), isSeeking(),
getPlaybackManager().
Normally getVideo() just returns document.querySelector('video').
But Jellyfin Desktop’s CEF+mpv player (and Jellyfin Media Player before
its rename) never creates a DOM <video> element at all — it exposes
window._mpvVideoPlayerInstance instead. When no real <video> is
found but that instance exists, getVideo() wraps it in a native
adapter: an HTMLMediaElement-shaped object (currentTime, paused,
playbackRate, readyState, seeking, play()/pause(),
add/removeEventListener) backed by polling the mpv instance every
NATIVE_POLL_MS (250ms) instead of real DOM events, so
playback/bind.js and playback/sync.js work unmodified against it.
Known limitation: the mpv player only exposes coarse
playing/pause/unpause/stopped/volumechange/error signals —
there’s no equivalent of HTML5’s waiting (buffering) event, so
buffering can’t be detected on this adapter, and its readyState/
networkState are approximations rather than real buffer-health
signals.
Module: ui/ — Interface
ui/render.js
render(forceFullRender?) — the main panel render, skipping a full
re-render if already showing the right view (lobby vs. in-room) and
just refreshing indicators/lists instead. renderLobby(panel) builds
the room list, “Create Room” button, and the Host Bridge section (see
Host Bridge). renderRoom(panel) builds the in-room
view (name, participants, sync indicator, chat, RTT, leave/close
button). Also injectOsdButton() (button in the video OSD controls)
and injectGlobalButton() (a persistent header button, since Jellyfin’s
SPA frequently replaces/removes the OSD — added so the launcher is
still reachable even with no video open). injectGlobalButton() tries
tryInjectLegacyHeader() first (Jellyfin 10.11’s .headerRight) and falls
back to tryInjectMuiToolbar() for Jellyfin 12’s default React/MUI layout,
where .headerRight still exists but is hidden by Jellyfin itself.
On Jellyfin 12 the button is a real in-flow child of the MUI toolbar’s own
actions Box — the same flex container that holds SyncPlay, RemotePlay and
Search (components/toolbar/AppToolbar.tsx). findMuiActionsBox() locates it
as the toolbar child immediately preceding the user-menu avatar’s Box, the
avatar being the only element in the toolbar with a stable semantic selector
(aria-controls="app-user-menu"). Because it is in flow, there is no
positioning math, no resize handling and no possibility of colliding with
another plugin’s button.
This works because React does not remove foreign children from containers it
manages: it reconciles against its own fiber tree, deletes only nodes it
created, and never enumerates the real child list. (Hydration is the one
exception, and jellyfin-web mounts with createRoot, never hydrateRoot.)
Verified against the real Jellyfin 12.0 production build in Chromium — the
button survives 200 route navigations, MUI menu churn and breakpoint changes
untouched.
findDonorButton() clones the class list off a neighbouring MUI IconButton
so the button is styled natively: MUI 6 keeps its real styling in
emotion-generated hash classes, and the stable Mui* names carry none, so
copying a live neighbour is the only way to match without hardcoding a hash
that changes with every MUI release. Measured against the live build this
gives a zero computed-style difference from a native toolbar button.
.jwp-global-btn-standalone is the fallback when no donor exists.
The button is absent exactly where it should be: findMuiActionsBox() returns
null on the video OSD and public paths (both render
isUserMenuAvailable={false}, so there is no avatar), and the admin dashboard
is excluded explicitly via the dashboardDocument class that
apps/dashboard/AppLayout.tsx puts on document.body.
observeToolbar() / disconnectToolbarObserver() drive re-injection from a
MutationObserver on document.body, coalesced through
requestAnimationFrame (one React commit emits many records, and
injectGlobalButton() mutates the DOM itself, so it would otherwise re-enter
its own observer). The UI_CHECK_MS poll in app/lifecycle.js remains only as
a safety net for the initial mount and for environments without
MutationObserver.
ui/cards.js + ui/home.js
Render the “Watch Parties” section on the Jellyfin home page.
ui/home.js’s renderHomeWatchParties() reconciles the DOM against
state.rooms (adding/removing cards, not a full re-render) and
ui/cards.js’s createRoomCard() builds each card, fetching cover
image/title from the Jellyfin API asynchronously. Clicking a card’s
play button navigates to the item’s detail page and polls for its
“Play” button to auto-click once the page has loaded.
ui/indicators.js
updateStatusIndicator() (connection Online/Offline dot),
buildSyncStatusIndicator()/updateSyncIndicator() (renders
state.syncStatus as a status dot + label — 'synced', 'syncing',
'pending_play' with a countdown, or 'unknown'/”Not synced yet” —
hidden entirely for the host, who has nothing to sync against).
ui/toasts.js
showToast(message) (centered, auto-dismissing system notifications)
and showChatToast(username, text) (top-right, stacked chat
notifications — capped at 5 visible, oldest dismissed first).
ui/styles.js
Injects the plugin’s CSS into <head> as a single <style> tag keyed
by STYLE_ID.
ui/bridge.js
Renders the Host Bridge picker inside the lobby panel — see Host Bridge for the full feature writeup.
Module: playback/ — Video Binding and Sync
playback/bind.js
bindVideo() attaches listeners to the active video element
(waiting/canplay/playing/play/pause/seeked) and starts a
STATE_UPDATE_MS interval that calls sendStateUpdate() while hosting.
cleanupVideoListeners() tears all of that down (called on room leave
or when the video player closes).
Host-side send gating (onHostEvent, one function handling
play/pause/seek uniformly): ignored entirely if not host, during the
isSyncing anti-feedback window, or (for pause) while buffering/seeking
(so HLS artifacts don’t get broadcast as real user actions); seeks are
additionally debounced (250ms) and required to differ from the last
sent position by at least SEEK_THRESHOLD. A waiting event also
proactively broadcasts a buffering player_event so guests pause too
instead of drifting ahead during the host’s stall.
playback/play.js
playItem(item) starts playback via the Jellyfin PlaybackManager,
trying several call signatures in turn (play({items}), play({item}),
play({ids}), playItems()) since the exact API has varied across
Jellyfin versions. ensurePlayback(itemId, attempt?) is how a guest
loads the host’s media on join — it no-ops if that item is already
playing, otherwise fetches it and calls playItem, retrying up to 5
times at 500ms intervals on failure.
playback/sync.js
watchReady()/notifyReady() send the ready message once the video
can play (readyState >= 2), which unblocks the server’s pending-play
handshake for late joiners (see Sync Algorithms).
syncLoop() (called every SYNC_LOOP_MS for non-hosts) is the
hysteresis drift controller documented in full in
Sync Algorithms: resets to 1x whenever not applicable (host,
not in room, not playing, buffering, paused), otherwise computes
drift = expected - video.currentTime and only starts a correction
burst once |drift| exceeds DRIFT_CORRECTION_ENTER_SEC, holding it
until |drift| falls back under DRIFT_CORRECTION_EXIT_SEC. There’s
also a distinct initial-sync phase right after joining
(checkInitialSync) that suppresses hard seeks for
INITIAL_SYNC_COOLDOWN_MS/INITIAL_SYNC_MAX_MS unless drift is
extreme (INITIAL_SYNC_MAX_DRIFT), since Jellyfin’s own resume-position
jump right after load would otherwise look like a real desync.
playback/tracks.js
Audio and subtitle track selection is deliberately outside the sync
protocol — every participant picks their own via Jellyfin’s normal player
controls, and only guests get this for free automatically (they never
broadcast anything). patchTrackSwitching() closes the remaining gap for
the host: it monkey-patches playbackManager.setAudioStreamIndex/
setSubtitleStreamIndex (feature-detected, since a track switch can force
Jellyfin to reload the stream and fire the same waiting/pause/
seeked/play events bind.js listens for) so that a host’s own local
track switch engages the isSyncing lock (see Sync Algorithms
§5A) for TRACK_SWITCH_SUPPRESS_MS instead of broadcasting a spurious
pause/seek to the room. A settle-shortcut (one-shot canplay/playing
listeners) collapses that window back down once the reload visibly
finishes, so a quick or no-reload switch doesn’t hold the room’s sync
events back for the full safety-net duration. Called idempotently from
app/lifecycle.js’s UI poll loop, same place bindVideo() runs.
Module: chat/ — Text Chat
chat/input.js
send(text) — validates length (MAX_MESSAGE_LENGTH 500) and
connection/room state, then sends a chat_message.
isChatVisible()/markRead()/updateBadge() manage the unread badge
shown when the chat panel is closed.
chat/messages.js
receive(msg) appends an incoming live message to chat.messages
(capped at MAX_MESSAGES 100 client-side), increments the unread count
and shows a toast if the panel is hidden, and renders it.
hydrate(entries) instead replaces chat.messages wholesale from the
server-replayed chat_history on room_state (see protocol.md) —
unlike receive(), it never touches the unread badge or fires a toast,
since it’s backfill for a joining/reattaching client, not a new live
message. renderAllMessages()/clear() handle full re-render and
room-leave cleanup.
Module: ws/ — WebSocket Communication
ws/send.js
send(type, payload?, roomOverride?) — the low-level message sender
(adds ts and client automatically). createRoom(password?),
joinRoom(id, password?), and leaveRoom() build on top of it;
leaveRoom() also resets all sync/drift state fields and hides the panel.
ws/auth.js
fetchAuthToken() — calls /JellyWatchParty/Token with the user’s
Jellyfin access token, waiting up to 10s for window.ApiClient to exist
if needed. Populates state.userName/userId/authEnabled regardless
of whether auth is enabled, and if enabled, stores the JWT and schedules
a refresh (scheduleTokenRefresh) at 80% of the token’s lifetime
(capped at 5 minutes before expiry) that re-authenticates over the
existing socket rather than reconnecting.
ws/connection.js
connect() opens the WebSocket, appending the client’s persistent
client_id (a UUID generated once and stored in localStorage, see
Server: Persistent Client ID) as a
?client_id= query param — this is what lets the server reattach a
reconnecting client to its existing room/host state instead of treating
it as new. onWsClose reconnects with exponential backoff
(RECONNECT_BASE_MS doubling up to RECONNECT_MAX_MS), not a fixed
delay. handleMessage dispatches each incoming message type to the
matching function in JWP._wsHandlers (populated by ws/handlers/*.js).
ws/handlers/room.js
handleRoomList, handleClientHello (stores the server-assigned
client_id used for the client message field — distinct from the
persistent reconnect client_id above), handleParticipantsUpdate/
handleClientLeft (toast + counter update), handleRoomClosed,
handleError.
ws/handlers/playback.js
handlePlayerEvent — applies a host’s play/pause/seek/buffering
command: activates startSyncing(), hard-corrects position if the gap
exceeds SEEK_THRESHOLD, then dispatches by action (play schedules
via scheduleAt(target_server_ts, ...) or applies immediately with lead
compensation; pause/seek/buffering apply directly and reset
sync-tracking state).
ws/handlers/sync.js
handleRoomState — applies full room state on join/reattach (host
detection, initial clock offset if not yet synced, initial seek +
play/pause, triggers ensurePlayback for guests) and
handleStateUpdate — the periodic host position/play-state relay,
gated by a post-command cooldown so it doesn’t fight with a just-applied
player_event.
ws/handlers/clock.js
handlePong — the clock-sync sample handler: computes RTT and a
candidate offset, keeps the best (lowest-RTT) of the last
TIME_SYNC_MAX_SAMPLES, and folds it into serverOffsetMs via EMA
(TIME_SYNC_EMA_ALPHA) once initial sync is established. Also drives
adaptive ping frequency: pings start fast (PING_INIT_MS) and switch to
the slow interval (PING_STABLE_MS) after PING_STABLE_AFTER
consecutive stable pongs, reverting to fast pinging if the offset jumps
by more than 50ms.
Module: app/ — Initialization and Cleanup
app/lifecycle.js
init() — injects styles, creates the (hidden) panel, connects the
WebSocket, and starts the tracked intervals (ui, home, sync — see
state.intervals). The ui interval (every UI_CHECK_MS) is also
where video-player-exit is detected: if a <video> was present on the
last check and now isn’t, onVideoPlayerExit() hides the panel, leaves
the room if in one, and cleans up video listeners. It also
unconditionally injects the persistent header button
(ui.injectGlobalButton()) since Jellyfin’s SPA frequently swaps out
the header DOM during navigation.
app/cleanup.js
cleanup() — full teardown for plugin unload: clears all tracked
intervals and the pending-action timer, closes the WebSocket, removes
panel and video event listeners, resets bound/initialized.
Synchronization Flow Diagram
┌─────────────────────────────────────────────────────────────────┐
│ HOST │
├─────────────────────────────────────────────────────────────────┤
│ [User clicks Play] │
│ │ │
│ ▼ │
│ onEvent('play') │
│ │ │
│ ├── Checks: isHost? shouldSend? !isSyncing? isVideoReady?│
│ │ │
│ ▼ │
│ send('player_event', {action:'play', position}) │
│ │ │
└────────┼────────────────────────────────────────────────────────┘
│
▼ WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ SERVER │
├─────────────────────────────────────────────────────────────────┤
│ Receives player_event │
│ │ │
│ ├── Validates: is host? │
│ ├── Updates room.state │
│ ├── Sets last_command_ts (cooldown) │
│ │ │
│ ▼ │
│ Broadcasts with target_server_ts = now + PLAY_SCHEDULE_MS │
│ │ │
└────────┼────────────────────────────────────────────────────────┘
│
▼ WebSocket
┌─────────────────────────────────────────────────────────────────┐
│ NON-HOST CLIENT │
├─────────────────────────────────────────────────────────────────┤
│ handleMessage('player_event') │
│ │ │
│ ├── startSyncing() → isSyncing = true for 2s │
│ ├── Update lastSyncServerTs, lastSyncPosition │
│ │ │
│ ▼ │
│ scheduleAt(target_server_ts, () => video.play()) │
│ │ │
│ ▼ │
│ [Video plays at synchronized time] │
│ │ │
│ ├── syncLoop() adjusts playbackRate for drift │
│ │ │
└─────────────────────────────────────────────────────────────────┘