watchparty/docs/architecture.md
Nicolas f6a8010647
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Move presence overlay to the top, orange badge for pause requests, series detail page
Two rounds of feedback on the previous commit, plus a related feature
request:

1. Presence overlay moved from bottom-left to the top of the player.
   client/js/views/player.js: the top gradient bar is now .topOverlay, a
   column of two stacked rows - .actionBar (what used to be the whole bar:
   title/members-text/next/settings/leave) and #presenceOverlay directly
   below it. Stacking them in normal flow means the avatar row always
   lands right after the action bar regardless of how tall that bar
   renders (it wraps on narrow screens), instead of a second
   absolutely-positioned block at a guessed pixel offset.

2. "Someone requested a pause" is now a specific orange border
   (.avatarPauseRequested, #ff9800) plus a small pause-icon badge in the
   circle's corner (.avatarPauseBadge - a plain inline two-bar SVG, not a
   Unicode glyph, since pause/play characters default to emoji
   presentation on several platforms and look muddy at 10px) - replacing
   the previous generic box-shadow pulse. This also absorbed the separate
   "click feedback" ring that used to live only on the requester's own
   circle (.avatarSent): since the server already broadcasts
   "pauseRequested" back to the requester too, one state driven purely by
   that broadcast is simpler than two overlapping ones.

   Found while repositioning the banner above .followerNotice: the
   notice is a <p>, and for an absolutely positioned element "bottom"
   places the MARGIN edge, so its default ~1em user-agent margin was
   quietly rendering it 13px higher than its declared bottom:24px ever
   suggested. Gave it margin:0.

3. Series detail page (client/js/views/series.js): the page now opens
   with an actual detail header - poster, title + year, overview, "Voir
   sur Emby" - above the season tabs/episode list, instead of a bare <h1>.
   The season/episode browsing and per-episode "Lancer une watch party"
   already worked end to end (library grid -> Series tile -> this route);
   this pass only made the page look like a detail page. Reused
   toItemDto's existing fields (GET /api/catalog/item/:id), no backend
   changes needed. Widened .linklike's CSS selector from
   `button.linklike` to `.linklike` so the new "Voir sur Emby" <a> picks
   it up too - app.css has no global `a` reset, so an unclassed anchor
   would otherwise render with the browser's default blue/underline.

Verified with mocked Socket.IO events / API responses against the real
running local server (see session history): presence row renders directly
under the action bar; a pauseRequested event gives the right circle
rgb(255, 152, 0) as its computed border color and display:flex on its
badge while leaving every other circle untouched (checked via computed
styles, not just class names); rapid repeat clicks on your own circle
still collapse to exactly one requestPause emission; banner/followerNotice
gap measured 15px after the margin fix, versus 2px before it; the series
page's header renders correctly, season-tab switching swaps the episode
list, a tvshows library's Series tiles link to /series/:id via both the
poster and "Voir les épisodes", and starting an episode's party correctly
creates it and navigates to the player.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 00:22:07 +02:00

31 KiB

Architecture

Goals

  • Watch the same title, in sync, with other users of the same Emby server.
  • Use Emby as the media source (catalog + streaming) without requiring changes to Emby itself or its official clients.
  • Access gated to people with an Emby account on this server — no anonymous use, no separate identity system.

Non-goals

  • Chat/reactions — deliberately out of scope.
  • OIDC/SSO — an OIDC provider (LemonLDAP::NG) is available in principle, but the explicit choice was Emby-only auth: simpler, no extra infra dependency, and everyone already has an Emby account.
  • Full library navigation (Shows → Seasons → Episodes) in v1 — search-based catalog only, to keep the first version achievable. Revisit if browsing turns out to matter more than searching in practice.

Why this replaced an Emby plugin

The first attempt (still in src/WatchParty/, kept for reference) was a real Emby Server plugin: sync via ISessionManager's existing remote-control commands, a settings page via IHasWebPages, and — after a fair amount of reverse-engineering — a real user-facing page integrated into Emby's own web client menu (PluginPageInfo.EnableInUserMenu, undocumented but confirmed working; required a data-controller AMD module to satisfy Emby's modern ViewManager, found by reading the target server's actual viewmanager.js).

That plugin builds and installs correctly. What it hit a hard wall on: a button inside the video player itself, or on an item's detail page. An Emby developer confirmed on their own forum that plugins have no API for this ("It's currently not possible for a plugin to do this"), and nothing found in research since suggests that's changed. The only way to get a button into Emby's own player would be patching Emby's shipped web client files directly inside the Docker container — outside the plugin entirely, guaranteed to break on every Emby image update, and requiring manual reapplication each time. That trade-off wasn't worth it.

Building a standalone app sidesteps the question entirely: it's our own player, so there's no "how do I add a button to someone else's UI" problem in the first place, and sync can be tighter than nudging Emby's remote-control commands ever allowed (see below).

Full trace of the plugin investigation (real errors, real fixes, sources consulted) is in the git history of this file and of src/WatchParty/ — worth reading if a future Emby SDK update ever makes in-player integration possible and this gets revisited.

Components

server/   Node.js + Express + Socket.IO. Owns party/room state and the
          realtime sync channel. Never touches a media byte - see "Media
          never flows through our backend" below.

client/   Static frontend, plain JS + ES modules, no build step (same bias
          as the earlier plugin's control panel). Talks to two different
          things and must not confuse them:
            - Emby directly (embyApi.js): catalog, images, streaming.
            - Our own backend (party.js): create/join/leave a party, and
              the Socket.IO connection for realtime sync.

Auth and Emby access: the server proxies everything

Revised after the first version: the browser used to call Emby directly (catalog, images, streaming) and only used server/ for party coordination. That hit real CORS problems in practice (Emby/its reverse proxy don't answer cross-origin preflights the way that needed), and separately wasn't the wanted shape anyway — the browser holding a real Emby access token at all was more exposure than necessary. Current design:

  • The browser sends a username/password once, to POST /api/auth/login on our own backend — never to Emby directly.
  • Our backend (server/src/embyAuth.js) calls Emby's POST /Users/AuthenticateByName itself, using EMBY_SERVER_URL from its own environment (not something the user ever types — see .env.example). On success it mints its own opaque session id, stores {embyToken, userId, userName} server-side (in-memory, like party state), and hands the browser only that session id, as an httpOnly cookie (wp_session). The browser never sees or holds Emby's real access token.
  • Every other request — catalog search, item details, images, HLS streaming, party actions — goes to our own origin only (/api/...), authenticated by that same cookie. server/src/embyProxy.js is what actually forwards these to Emby server-side and streams the response back, attaching the session's real Emby token itself.
  • The /api/catalog/stream/:itemId/master.m3u8 and .../raw routes proxy HLS specifically: the master playlist is fetched from Emby server-side, and every URI line in it is rewritten to point back through .../raw (whether Emby wrote it as relative or absolute) — otherwise hls.js would follow the playlist's own URLs straight to Emby the moment it tried to fetch a segment, defeating the whole point. See rewritePlaylist in embyProxy.js.
  • Images (<img src>) and video (hls.js/<video>) still can't carry a bearer header, but that's fine now: they're same-origin requests to our own /api/... paths, so the session cookie rides along automatically — no token in any URL anywhere, unlike the first version's ?api_key=....

This also means the client no longer needs to ask for a server URL at all — one less field, one less way to get it wrong. EMBY_SERVER_URL lives only in the backend's environment.

Control model: host drives, everyone else follows

Now that we own the player, the classic watch-party model (Teleparty, Amazon Watch Party, ...) fits better than the old plugin's "everyone keeps their own controls, shared reference position" — that design was specifically a concession to nudging pre-existing, independently-running Emby app sessions we didn't control. Here, there's only one player implementation, so:

  • The party creator is the host. Their player has normal controls.
  • Everyone else's <video> has controls hidden; a small notice says the host is in charge.
  • Host → server → everyone else, over Socket.IO (server/src/socketHandler.js):
    • Event-driven: every play/pause/seeked on the host's video sends {type:"playstate", isPaused, positionTicks} immediately.
    • Heartbeat: also sent every 3s regardless, so latecomers/drift get corrected even without a discrete host action.
    • Followers apply pause/unpause immediately, and only seek if drift exceeds 1.5s (DRIFT_TOLERANCE_SECONDS in client/js/views/player.js) — avoids fighting small timing noise with constant re-seeks.
  • If the host leaves, the server promotes another member (same fallback logic the old plugin used for its "reference" member).

Party lifecycle

In-memory only on the backend (server/src/partyManager.js) — a restart clears every party, which is fine: a party is meant to last one watch session, not persist for weeks. removeIdleParties sweeps anything with no activity for 4 hours.

Realtime transport: Socket.IO, and two real memory leaks

The realtime layer was originally a raw WebSocket (the ws package). Two things prompted a rework — a requirement (use Socket.IO instead) and a specific worry (memory leaks) — and auditing for the second turned up two real, concrete leaks, independent of which transport library is used:

  1. Server-side: dead connections never got cleaned up. A raw ws server has no idea a client is gone unless it receives a proper close frame. A laptop put to sleep, a network drop, a killed tab that never gets to send one — none of those fire ws's 'close' event, so the PartyMember's socket reference (and everything it closes over) would sit in memory forever, and the party would never get cleaned up by anything (removeIdleParties only looks at lastActivityUtc, which a phantom connection doesn't affect). ws's own docs recommend a manual ping/pong heartbeat to detect this - Socket.IO has one built in (pingInterval/pingTimeout), which is a real, not just cosmetic, reason to prefer it here, not only the requested API ergonomics.
  2. Client-side: leaving the player any way other than its own "Quitter" button leaked everything. router.js only ever did currentRoot.innerHTML = '' between routes — that destroys DOM nodes, but nothing told the outgoing view's JavaScript to stop. Clicking a sidebar link or the browser's back button while watching left the player's 3-second heartbeat setInterval, its realtime connection, and its hls.js instance all running against a detached <video> element indefinitely - for as long as the tab stayed open. Fixed by having route() handlers optionally return a cleanup function; router.js calls whatever the previous view returned right before rendering the next one, regardless of how navigation happened. player.js returns one that clears the heartbeat, destroys hls, disconnects the socket, and removes the one document-level click listener it adds (named, not inline-anonymous, specifically so it's removable at all - an anonymous listener added fresh on every visit would itself have been a slow leak even with everything else fixed).

Both are now covered by the same class of test: drive the router through several "join a party, then navigate away without clicking Quitter" cycles and check nothing accumulates. Verified directly (see session history) by instrumenting document.addEventListener/removeEventListener and setInterval/clearInterval around three such cycles against a really-running local server (Socket.IO connection included, not mocked) - adds and removes matched exactly (3/3 listeners, 2/2 intervals across two cycles), nothing left pending. The server-side heartbeat fix is Socket.IO's own well-established mechanism, not custom code, so it wasn't separately instrumented the same way.

server/src/socketHandler.js mirrors the old wsHandler.js protocol closely (see its own top comment for the exact event list) - the one naming difference is the error event, sent as "errorMessage" rather than "error", since Socket.IO reserves the latter for its own internal error handling.

Live lobby, host handoff, no join codes

Three related changes, all in service of "the home page should reflect reality without a refresh":

  • The home page's party list is a live subscription, not a fetch. client/js/party.js's connectLobby joins a "lobby" Socket.IO room (subscribeLobby → server/src/socketHandler.js) and gets a fresh snapshot on subscribe, then again every time broadcastLobby(io) runs - which is now wired into every place party membership changes: creating a party, joining one (both in index.js's REST handlers), and leaving (applyLeave, used by both the socket leave/disconnect handlers and the REST leave endpoint, so this - and the host-handoff notification below
    • only has to be right in one place). home.js returns this subscription as part of its router cleanup function, same as the player does for its own socket - an open lobby subscription left running after navigating away would have been exactly the same class of leak just fixed above.
  • Host and elapsed time are shown per party, per the request. Host name and member count come from the same snapshot. Elapsed time doesn't: it ticks client-side from a createdUtc timestamp the server includes once (home.js's setInterval re-renders every .elapsed label every second) - pushing a recomputed duration from the server every second for every viewer would be pure waste when the client can derive it from one number. Verified live (see session history): faking a parties push with a createdUtc two minutes in the past and waiting rendered "2:06", not a static value.
  • No join codes. PartyManager no longer generates or tracks one - see its class comment. The home page's live list is now the only way to find a party; a code that has to be copied and typed out of band doesn't add anything on top of a list that's already always current. POST /api/parties/:partyId/join replaces the old :joinCode route.
  • Host handoff is live, not just correct in the data model. PartyManager.leave already promoted another member to host when the host left and destroyed the party once empty - that part predates this change. What was missing: a player already open as a follower had no way to find out it became host mid-session. applyLeave now emits a personal "hostChanged" (to that one socket, not the room) when this happens; player.js's becomeHost() - the same function init() calls when you already are the host - unlocks the controls live, no reload needed. becomeHost is written to tolerate being called more than once safely: addEventListener with the same function reference is a no-op if already attached, and the heartbeat setInterval is explicitly guarded (if (!heartbeat)) since setInterval has no such built-in idempotence - calling it twice would leak the first interval's id, the exact class of bug the rest of this pass was about fixing, not reintroducing.

One incidental bug found and fixed while touching this code: the home page's "Rejoindre" button on a live party card used to navigate() straight to the player without ever calling the join endpoint first - it worked well enough to load the video and read basic party info (GET /api/parties/:id never checked membership), but the realtime "join" would be rejected server-side ("Tu n'es pas membre de cette party.") since socketHandler.js's join handler does check membership - meaning sync silently never worked for anyone who joined this way. Fixed by awaiting joinParty() before navigating, same as the library grid's tiles already did correctly.

Player: no page scroll

.playerWrap was width:100%; height:100vh sitting inside .mainContent, which has its own padding - the two stacked (100vh plus padding) made the whole shell taller than the viewport, so the page grew a scrollbar even though the video itself was already full-height. Changed to position:fixed; inset:0 so it's sized against the real viewport regardless of any ancestor's padding, and (like most players' theater mode) covers the sidebar while watching - confirmed via computed style (position: fixed, all four insets 0px) rather than just asserted.

Presence overlay and "request pause"

Requested as a pair: a semi-transparent overlay showing who's in the party, and a way for a non-host to ask the host to pause without a chat feature (deliberately out of scope — see Non-goals). Revised once after the first pass, based on concrete feedback: the overlay moved from bottom-left to the top of the player, and the "someone requested a pause" indicator became a specific orange border + pause-icon badge rather than a generic pulse animation.

  • Layout: the player's top gradient bar is now .topOverlay, a column of two stacked rows — .actionBar (title/members-text/next/settings/leave, what used to be the whole bar) and, directly below it, #presenceOverlay. Stacking them in normal flow inside one column means the avatar row always lands right after the action bar regardless of how tall that bar renders (it wraps to two lines on narrow screens) — deliberately not a second absolutely-positioned block at a guessed pixel offset, which would either overlap the action bar when it wraps or leave an inconsistent gap when it doesn't.
  • Presence: client/js/views/player.js's renderMembers() — already responsible for the text member list — now also rebuilds #presenceOverlay, one circle per member. Each circle shows the first two characters of userName, uppercased (initialsFor), on a background color picked deterministically from a small fixed palette by hashing the name (colorForName) — same person, same color, every render, with no need for the server to assign or track one. opacity: .75 on .avatar is the "semi transparence" itself, applied to the whole circle (initials included) rather than just its background, since a chat-bubble-style translucent chip reads clearly enough at that opacity and avoids converting the hash color to an rgba string just to keep text fully opaque.
  • Which circle is "yours" is decided client-side by comparing member.userName === auth.userName (the same auth singleton embyApi.js already populates at login/session-restore) rather than by userId — consistent with the rest of the client already treating userId as a server/protocol-internal detail (see hostChanged's empty payload) and not something to key UI logic on directly.
  • Request pause: your own circle — only when you're not the host, the host already has direct controls — gets .avatarClickable and a click handler that calls the party connection's requestPause() (client/js/party.js → "requestPause" socket event). The server (server/src/socketHandler.js) broadcasts "pauseRequested" {userId, userName} to everyone in the party, not just the host, so the whole group sees why playback is about to pause, and throttles it server-side per member (5s, member.lastPauseRequestUtc) independently of the client's own identical cooldown (PAUSE_REQUEST_COOLDOWN_MS in player.js) — the server-side one is the real guard (anyone poking the socket directly bypasses a client-only check); the client one just avoids a pointless click doing nothing.
  • Receiving "pauseRequested" shows a short banner (#pauseRequestBanner, "<name> demande une pause") and flags the matching person's circle (.avatarPauseRequested: an orange #ff9800 border plus a small pause-icon badge in its corner — .avatarPauseBadge, a plain inline two-bar SVG rather than a Unicode glyph, since pause/play characters default to emoji presentation on several platforms and look muddy at 10px) — both matched by the event's userId against each circle's data-user-id, both clearing automatically after PAUSE_REQUEST_HIGHLIGHT_MS (4s), for every member, including the requester and the host — deliberately not host-only, so a request landing while the host isn't looking at the tab is still visible to everyone else, and the requester gets confirmation their own click registered. This replaced an earlier plain box-shadow pulse animation and a separate immediate-local-feedback ring on just the requester's own circle (.avatarSent) — collapsed into one unambiguous visual language driven purely by the server broadcast (which the requester also receives, so there's no need for a separate client-only echo).
  • .followerNotice picked up a margin: 0 it was missing: it's a <p>, and for an absolutely positioned element bottom places the margin edge, so its default ~1em user-agent margin was quietly rendering it 13px higher than its declared bottom: 24px ever suggested. Harmless on its own until .pauseRequestBanner needed to sit a specific gap above it — found by measuring both rects and seeing the gap read as 2px instead of the ~15px the two bottom values alone implied.
  • Nothing about this feature actually pauses anything automatically — "request" is literal, the host still has to act on it. Adding an actual forced-pause would be a real design decision (can any member override the host's playback state? that cuts against "host drives, everyone follows") and wasn't asked for.

Verified end to end with mocked members/pauseRequested Socket.IO events against the real running local server (see session history): the presence row renders directly under the action bar; initials/colors render correctly for a two-member party; the host's circle never gets .avatarClickable (neither when viewed by themselves nor by others); a pauseRequested event gives the right circle rgb(255, 152, 0) as its computed border color and display: flex on its badge (confirmed via computed styles, not just class names) while leaving every other circle untouched; rapid repeat clicks on your own circle within the cooldown collapse to exactly one requestPause emission (three synchronous clicks in one script → the emitted-event count increased by exactly one — an earlier two-separate-clicks check had looked like a cooldown failure at first, but that was real wall-clock time between two separate tool calls exceeding the 5s window, not a code bug); and the banner/.followerNotice gap measured 15px after the margin fix, versus 2px before it. The host-only controls (becomeHost, settings menu, next-episode button) were re-checked after this change and still work.

Assumptions to verify

Confirmed against the real server (not a guess — actually exercised, see session history): POST /Users/AuthenticateByName really is reachable from Node's server-side fetch with no CORS issue at all (server-to-server has no CORS concept), returns a real 401 for bad credentials, and the whole login round-trip (browser → our backend → real Emby → real rejection → our error message) renders correctly end to end. The --theme-primary-color-* etc. custom properties client/css/app.css is built from were read directly off this exact server's live dark theme, not guessed.

Not yet exercised against the real server — a successful login hasn't happened yet (no real credentials used in testing), so nothing past that point is confirmed:

  • /Users/{userId} for token validation was dropped — the proxy redesign no longer needs it at all (sessions are validated once at login and cached server-side, not re-checked against Emby per-request).
  • Streaming: the first version hand-built /Videos/{id}/master.m3u8?VideoCodec=h264&AudioCodec=aac directly, and it broke in the field: Emby's server threw ArgumentNullException: Value cannot be null. (Parameter 'key') — almost certainly an internal dictionary keyed by something that URL never supplied (MediaSourceId, PlaySessionId, DeviceId are the usual suspects). Fixed by calling GET /Items/{id}/PlaybackInfo?UserId=... first and using the MediaSource's own TranscodingUrl — the same thing real Emby clients do — falling back to a hand-built URL (now at least carrying MediaSourceId/PlaySessionId/UserId from that response) only if no TranscodingUrl comes back. Not yet confirmed against a real response: whether TranscodingUrl is reliably present, and whether it's always HLS (a direct-play-eligible source might not transcode at all, which hls.js on the client wouldn't handle - see Known limitations).
  • HLS playlist rewriting (rewritePlaylist in embyProxy.js) — the logic is straightforward (rewrite every non-# line) but has only been reasoned about, never run against a playlist Emby actually generated. If video loads the manifest but segments 404 or hang, start here.
  • Response field names (AccessToken, User.Id, User.Name, ServerId, Items[].Id/Name/Overview/ProductionYear/Type, /Users/{id}/Views returning library folders the same shape as regular items) — long-standing Emby/Jellyfin convention, not independently re-confirmed for this Emby version.
  • X-Emby-Authorization header format — same caveat as above.
  • The Emby web client deep-link shape (/web/index.html#!/item?id=X&serverId=Y) used for "Voir sur Emby" — matches the long-standing convention, not confirmed against this exact build's routing.

UI: Emby-like shell, per-type browsing, episode queue

Reworked to look and navigate more like Emby itself, and to treat each collection type (movies / TV / music) as its own experience rather than one generic grid:

  • client/js/shell.js renders the persistent chrome once per login: a sidebar (collections from GET /api/catalog/libraries, one icon per collectionType) + a #mainContent element the router (router.js) swaps on every navigation. router.js was made idempotent (start(root) can be called again safely) specifically so login → shell boot can happen more than once in a page's lifetime without piling up duplicate hashchange listeners — the same class of bug the earlier Emby plugin work ran into with its page lifecycle, worth remembering.
  • views/home.js (route /home): a horizontal carousel of every active party server-wide (GET /api/parties, deliberately not filtered to "your" parties — the point is letting people discover and join one without a code passed out of band) plus a join-by-code fallback, then one more carousel per library of its recently-added items (GET /api/catalog/libraries/:id/recent, Emby's own /Users/{id}/Items/Latest) — skipping music (see below).
  • views/library.js (route /library/:id): dispatches purely on the library's collectionType. movies/tvshows/anything else gets a tile grid (GET /api/catalog/libraries/:id/items, Recursive=false — Movies as movies, TV as Series tiles); music gets an honest "not built yet" message instead of a half-working page (see Non-goals).
  • Tiles (client/js/tiles.js, shared by the home carousels and the library grid so they can't drift apart): poster + name, hover-revealed overlay with "Lancer une watch party" (only for directly-playable types — a Series tile gets "Voir les épisodes" instead, linking to /series/:id) and "Voir sur Emby" (opens Emby's own web client via a embyUrl the backend builds from EMBY_SERVER_URL + the session's serverId). :hover/:focus-within reveal it on mouse/keyboard; @media (hover: none) keeps it always visible on touch devices, which have no hover state at all.
  • views/series.js (route /series/:id): a detail header — poster, title + year, overview, a "Voir sur Emby" link — above season tabs (GET /api/catalog/series/:id/seasons, Emby's /Shows/{id}/Seasons) and an episode list per season (GET /api/catalog/series/:id/episodes, Emby's /Shows/{id}/Episodes?SeasonId=...), each episode startable as its own party. The header is just toItemDto's existing fields (GET /api/catalog/item/:id, the same DTO shape as everything else) — the season/episode browsing itself predates this pass and was already wired end to end (library grid → Series tile → this route → "Lancer une watch party" per episode); what this pass added was making the page actually look like a detail page instead of a bare <h1> above the tabs. .linklike's CSS selector was widened from button.linklike to .linklike so the new "Voir sur Emby" <a> picks it up too — there's no global a reset in app.css, so an unclassed anchor would otherwise fall back to the browser's default blue/underline against this dark theme.

Episode queue / "next episode"

Starting a party from an episode (POST /api/parties) now has the backend fetch the rest of that series (/Shows/{seriesId}/Episodes, no season filter) and store everything from the clicked episode onward as the party's queue (server/src/partyManager.js). The host gets an "Épisode suivant" button once hasNext is true; clicking it (or the video's ended event firing) sends a "next" Socket.IO event (server/src/socketHandler.js), host-only, which advances queueIndex, resets every member's known position, and broadcasts {type:"itemChanged", itemId, itemName, ...} — every client (including the host) reloads the player against the new itemId on that message. A movie or a standalone-started episode just gets a one-item queue, so this is a strict superset of the old single-item behavior, not a separate code path.

Player: language, subtitles, quality

GET /api/catalog/item/:id/playback-options reads PlaybackInfo's MediaSources[0].MediaStreams and returns the Audio/Subtitle entries as {index, label} pairs for two <select>s in the player's host-only controls. Picking a value reloads the stream (/api/catalog/stream/:id/master.m3u8?AudioStreamIndex=&SubtitleStreamIndex=&MaxStreamingBitrate=) at the same position (hls.js destroyed and rebuilt, currentTime/paused state preserved across the reload) rather than restarting from zero. "Quality" is a fixed set of MaxStreamingBitrate presets (Auto/1080p/720p/ 480p) forwarded to PlaybackInfo — the same simplification Emby's/ Jellyfin's own web clients use; it's a transcode ceiling, not true client-side adaptive bitrate switching. Subtitles picked this way get burned into the transcoded video (Emby's doing, via SubtitleStreamIndex) rather than rendered as a toggleable <track> — simpler to implement correctly than sidecar WebVTT, at the cost of a re-transcode to turn them on/off.

Confirmed vs. not, for all of the above

Driving an actual browser against this (see session history) confirms the rendering and interaction logic: sidebar highlighting, home carousels (parties + one row per non-music library), library grid dispatch (including a tvshows library's Series tiles linking to /series/:id both via the poster and the "Voir les épisodes" button), and the series page's detail header + season-tab switching + episode list + starting a party from an episode all render and navigate correctly. That test mocked every /api/... response (canned JSON), since exercising it for real needs a logged-in session — so what's not confirmed yet is the shape of the real Emby responses this now depends on: Views items actually carrying a CollectionType field the way assumed, /Shows/{id}/Seasons and /Shows/{id}/Episodes, Items/Latest's plain-array response shape (handled defensively — see index.js — but not seen for real), and PlaybackInfo's MediaStreams shape for the track pickers. The queue/"next episode" WebSocket flow and the player's track/quality reload are new protocol surface with no test coverage at all yet (no way to exercise real HLS playback without a live session) — the first things to check once a real party actually plays something.

Non-goals (updated)

Music was requested (dedicated audio player, artist/album browsing, a shared/collaborative playlist party members can add to together) but is deliberately not in this pass: it's a genuinely different subsystem — a different player paradigm, full library-depth browsing down to the track, and real-time collaborative queue state that doesn't fit the existing "host drives, everyone follows" model at all (multiple people adding to one shared list is a different kind of shared state than one host's playback position). The sidebar and library routing already treat music as a first -class collectionType specifically so slotting in a real implementation later doesn't need another structural pass — see library.js's early return. Right now it just shows an honest placeholder.

Known limitations

  • Playback always goes through hls.js against whatever Emby's PlaybackInfo suggests. If that ever comes back as a direct-play source with no TranscodingUrl (no transcoding needed/possible), the client has no fallback path yet - it'll try to hand hls.js something that isn't HLS.
  • One item per party; no shared queue.
  • A party member needs a browser tab open and connected for sync to apply — there's no "catch up automatically when you come back" beyond whatever the next heartbeat corrects.
  • No persistence: server restart or long idle time ends every party.