` 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.
**Much later: reported as "the elapsed time shown is the movie's elapsed
time" - correct, but that was the bug.** The `createdUtc`-based label
above was never wrong about what it computed (party age), but ticking
wall-clock time regardless of pause state means it only ever *reads* as
the movie's own elapsed time by coincidence, for as long as nobody pauses
- once someone does, the two drift apart, and the number on the home page
keeps climbing through a pause the actual player has frozen. Fixed by
giving `LobbyPartyDto` the host's real position instead, for video
parties only (`positionTicks`/`isPaused`/`positionUpdatedUtc` - null for
music, where there's no single "elapsed" across a rotating queue of
tracks the way there is for one movie/episode - `Home.ts` falls back to
the original `createdUtc` behavior for those). The host is already
authoritative for this inside their own party's room (`"playstate"`); the
lobby snapshot just reads the same tracked value `SocketGateway` already
had, rather than a party-level position needing to be kept in sync
separately. `broadcastLobby` gained two more trigger points to keep this
reasonably fresh - an actual pause/play transition (not every 3s
heartbeat resend, same discipline as the existing playstate-logging
guard) and advancing the queue (`"next"` - `PartyService.advanceQueue`
already reset every member's position to zero for the new item, but
nothing had ever pushed that reset to the lobby before, so the card could
show a stale position, and a stale title/poster, until some unrelated
join/leave happened to refresh it). Verified against the real bundled
client with a faked `parties` push: a paused party's label held exactly
still across a real wait; a playing one, and a music party still on the
old `createdUtc` path, both kept ticking up 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`,
"`` 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 ``,
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.
**Much later: a request toolbar replaced the avatar click, and grew a
second request.** Asked for directly: a visible toolbar for non-host
members instead of "click your own small circle" (never a very
discoverable affordance), and a second request alongside pause -
"please resume". `SocketGateway` gained `requestResume`/`resumeRequested`,
byte-for-byte the same shape as `requestPause`/`pauseRequested` and
independently throttled (its own `lastResumeRequestUtc`, not a shared
budget with pause - asking to pause right after asking to resume, or vice
versa, isn't the "immediate repeat" this throttle exists to stop). Kept
as a plain signal, deliberately, same as pause always was: broadcast to
everyone, no accept/deny state tracked anywhere - the host sees the same
banner/avatar highlight as everyone else and acts with their own existing
controls. `Player.ts`'s markup gained a `.bottomOverlay` wrapping the
banner, the follower notice, and the new toolbar as one flex column
(same "stack in normal flow, don't hand-pick a pixel offset per box"
fix `.topOverlay` already needed - see just above) - the two request
buttons, the banner, and the avatar highlight all share one
`showRequestBanner()` now, since pause and resume only ever differed by
which word appears in the banner text. Verified against the real bundle:
the toolbar shows for a follower and stays hidden for the host; each
button's own client-side cooldown collapses a rapid double-click to one
`requestResume` emission; a pushed `resumeRequested` shows the banner
with the right text and highlights the right avatar.
**Much later still: moved the toolbar off the subtitle zone.** Asked
for directly - `.requestToolbar` shared `.bottomOverlay`'s centered
column with the banner/follower-notice above, which is also exactly
where a native `` subtitle renders (see "Player track
selection" below): real, clickable buttons sitting on top of subtitle
text, for the whole session, is a worse problem than a transient
banner or static notice occasionally doing the same. Pulled `#request
Toolbar` out into its own sibling block in `Player.html`, positioned
independently in the bottom-right corner (`position: absolute; right:
16px; bottom: 20px`) and stacked as a column instead of a row (`align-
items: flex-end`) so two pill buttons never have to fight a narrow
phone screen for width side by side. `.bottomOverlay` itself is
untouched - still centered-bottom, still just the banner and the
notice, neither of which is something a viewer needs to click through
subtitle text to reach. Verified against the real bundle at both
desktop and phone width: the two buttons render bottom-right,
non-overlapping with the (still centered) follower notice, in both
widths.
## Player track selection: forcing our pick onto Emby's own negotiation
First real-world bug report once someone actually watched something end to
end: picking a language or subtitle track in the settings menu had no
effect on playback. Streaming itself working at all (this is the first
confirmation of that - see below) narrowed it down: `PlaybackInfo` and its
`TranscodingUrl` are reachable and produce a playable HLS stream, but the
specific track requested wasn't the one that came out the other end.
Likely cause, reasoned from how Emby/Jellyfin's own web client actually
negotiates this (not verified against this exact server - no way to, from
here): that client sends `AudioStreamIndex`/`SubtitleStreamIndex` as part
of a `POST /Items/{id}/PlaybackInfo` body alongside a `DeviceProfile`, not
as bare query-string params on a `GET` - which is all this app has ever
sent. Emby's `TranscodingUrl` reflects whatever `PlaybackInfo` actually
negotiated, so if the plain `GET` doesn't apply those params, the returned
URL just encodes Emby's own default track choice regardless of what we
asked for - and the old code used that URL as-is. Separately, even a
correctly-selected subtitle track has a second way to silently not show up:
Emby can deliver text subtitles either burned into the video
(`SubtitleMethod=Encode`) or as their own selectable HLS rendition
(`SubtitleMethod=Hls`) - this player's hls.js integration never wires up
`subtitleTrack`/`subtitleTracks` to pick a rendition, so a `Hls`-method
subtitle would be present in the manifest but never actually rendered.
Fixed defensively rather than by trying to exactly replicate Emby's own
client (`server/src/index.js`'s `/master.m3u8` route + its new
`applyPlaybackOverrides` helper): whichever URL we end up with - Emby's own
`TranscodingUrl`, or the hand-built fallback (which, before this fix,
dropped the audio/subtitle/quality query params entirely - a second,
independent way the same symptom could happen if a title ever took that
path) - our own `AudioStreamIndex`/`SubtitleStreamIndex`/
`MaxStreamingBitrate` are forced onto its query string afterward,
overwriting whatever Emby put there. Picking a subtitle also forces
`SubtitleMethod=Encode`; picking "Aucun" removes both
`SubtitleStreamIndex` and `SubtitleMethod` rather than just not adding one,
so it also cancels a subtitle Emby's own negotiation had defaulted to. This
means the negotiation call itself no longer has to be trusted at all - it
only needs to return *a* playable `MediaSource`, since every parameter this
app's UI actually exposes gets forced afterward regardless.
Verified as far as possible without real Emby access: `applyPlaybackOverrides`
is pure `URLSearchParams` manipulation, unit-tested in isolation (adding a
subtitle Emby didn't pick, removing one Emby had defaulted to, re-sending an
unchanged selection across an audio-only change, quality override, and the
no-selection-yet initial-load case) - all five cases produce the expected
final query string. Separately confirmed client-side, by mocking `Hls` to
capture what URL `loadSource` was actually called with: the initial load
already sends the default-selected track's index, and each dropdown change
re-sends a full reload with exactly the accumulated params expected
(switching audio → `AudioStreamIndex` changes; adding a subtitle → both
params present; back to "Aucun" → `SubtitleStreamIndex` dropped again).
What's **not** verified is the actual Emby round trip - whether
`applyPlaybackOverrides`'s forced params, once they reach the real server,
produce a transcode that really carries the requested track. That needs a
real retest.
**Round 2, after a retest: subtitles specifically still didn't show.**
Found a real gap in the fix above: `applyPlaybackOverrides` was only ever
applied to the *final* stream URL, not to the `PlaybackInfo` **negotiation**
call itself - so `SubtitleMethod=Encode` never reached Emby until after it
had already decided, during negotiation, whether this playback session
would be a full re-encode or a plain remux. Burning a subtitle into the
video is only possible with a real re-encode - appending
`SubtitleMethod=Encode` to a URL for a session Emby had already set up as a
remux plausibly does nothing, regardless of how correct the URL looks.
Fixed by applying `applyPlaybackOverrides` to the negotiation's own params
too (`server/src/index.js`), so Emby is asked to encode *before* it decides
how the session will actually be handled, not just told afterward.
Unit-tested (see session history): the negotiation call now measurably
carries `SubtitleMethod=Encode` whenever a subtitle is requested, same as
the final URL always did.
Also added `server/src/logger.js`'s `streamLog` (`[watchparty:stream]`,
alongside the party-lifecycle `partyLog` added separately - see "Server-side
logging" below) specifically because two rounds of blind, reasoned-but-
unverified fixes for this one bug is the point where guessing stops being
efficient: one `master.m3u8` request now logs its `itemId` and the
resolved `audio`/`subtitle`/`subtitleMethod`/`quality` values (never the
full URL or params blob - Emby's own `TranscodingUrl` can carry an
`api_key`, confirmed kept out of the log line by a dedicated test case),
so the next retest can confirm from `docker logs`/journalctl alone whether
a requested override actually reached Emby, without needing a devtools
session on whatever machine runs the server. If subtitles still don't show
once this log line confirms `subtitleMethod=Encode` really was sent, the
fault has been narrowed to Emby's own handling of that parameter rather
than anything before it in this app.
**Round 3, after a real retest: the override reaches Emby correctly - it's
just slow.** Not a repeat of Round 2's bug. Confirmed directly from the
browser's own error report this time (`SubtitleMethod%3DEncode` present,
URL-decoded, right there in the failing fragment's own request URL - even
better evidence than the server-side stream log, since it's the literal
resolved URL rather than what this app computed before sending it), so the
override chain genuinely works end to end: Emby *is* being asked to burn
the subtitle in, and does. The failure is downstream of that, in a place
Round 1/2 had no reason to look at: `hls.js` is constructed with no config
at all (`new Hls()`, defaults only), and its default fragment-load timeout
is 10 seconds - fine for a source Emby can remux almost instantly, too
tight for a source Emby has to fully re-encode from scratch, which
`SubtitleMethod=Encode` always requires (see the "not a rendition" reasoning
above - burning a subtitle in is fundamentally a heavier operation than
picking an audio track or even changing quality, neither of which
necessarily forces a re-encode the same way). hls.js retried 4 times, each
timing out the same way, then gave up with a fatal `fragLoadTimeOut` -
correctly reported by the existing error handling, just for a cause
("Emby hasn't finished transcoding yet") this player had never actually
hit before subtitles specifically forced a re-encode on what was probably,
until now, mostly direct-play/remux traffic.
Fixed by giving both players' `Hls` instances explicit, longer timeouts
(`manifestLoadingTimeOut`/`levelLoadingTimeOut`: 30s, `fragLoadingTimeOut`:
60s - `views/player/Player.ts`'s `HLS_CONFIG`, duplicated in
`musicBar/MusicBar.ts` for the same reason the rest of their hls.js setup
already is) rather than only patching the video player: music tracks go
through the exact same `PlaybackInfo` → transcode pipeline (see "Music:
participative playlist" above), so a slow-to-start transcode is a latent
risk there too, quality changes included, even though only video actually
has subtitles to trigger it today. Verified the config values are actually
honored, not silently ignored by a wrong property name, by constructing a
real `Hls` instance from the exact pinned CDN build
(`hls.js/1.5.17/hls.min.js`) with this config and reading back
`hls.config.fragLoadingTimeOut` etc. - all four came back as set, not as
hls.js's own defaults.
**Round 4: a real 500, once the timeout was no longer masking it - traced
to Emby, not this app.** Exactly what Round 2 said would happen "if
subtitles still don't show once this log line confirms `subtitleMethod=
Encode` really was sent": with Round 3's timeout fix giving Emby enough
rope to actually answer instead of hls.js giving up first, the segment
request came back with a real, fast 500 - and Emby's own server log
(shared directly, not this app's) shows exactly why: `[subtitles@f2] Failed
to load fontconfig fonts!`, thrown out of `StartFfMpeg`/
`GetDynamicSegment` before a response body ever exists. That's ffmpeg's
`subtitles` filter (burning text into the video via `libass`) failing to
find usable fonts/a fontconfig cache in whatever environment Emby's
transcoder runs in - a deployment/environment issue on the Emby side
(commonly a container image missing font packages, or a fontconfig cache
directory that isn't writable), with nothing in this app's request that
could cause or fix it. Confirmed not a regression to work around here:
this app deliberately forces `SubtitleMethod=Encode` (see
`applyPlaybackOverrides`'s own comment) specifically because it never
wires up `hls.js`'s rendition-based subtitle API - dropping that override
to dodge this 500 would silently bring back the very first version of
this bug (a subtitle picked, never shown) instead of fixing anything.
Nothing to change in this app for this one; it needs fonts/fontconfig
fixed wherever Emby's own container/host runs.
**Round 5: fontconfig fixed, and a *different* item still failed the same
way - added a real check instead of guessing further.** After Round 4's
fix, the font loaded fine (`Loading font file '/config/fonts/
GoNotoKurrent.ttf'`, no more "Failed to load fontconfig fonts") - but a
retest on the same item still failed, this time with `Could not find
codec parameters for stream N (Subtitle: hdmv_pgs_subtitle (pgssub))`.
`hdmv_pgs_subtitle` (PGS) is a Blu-ray **image/bitmap** subtitle format -
a pre-rendered picture per frame, not text. This app's forced
`SubtitleMethod=Encode` asks ffmpeg's `subtitles` filter to burn it in,
and that filter is `libass` - it renders *text* via a font (exactly what
Round 4 just fixed the ability to do), and has no code path for
compositing a bitmap at all. Picking a PGS track was never going to work
here, fontconfig or not. A second item, tested specifically to rule PGS
in or out as the whole explanation, hit the exact same wall - without
knowing that item's own subtitle codec, that result was genuinely
ambiguous (still PGS on a second file? or something else entirely?),
which is exactly the kind of guess this saga had already spent four
rounds on.
Rather than keep asking for one more log line per item, `getPlaybackOptions`
(`CatalogService.ts`) now reads each subtitle stream's own `Codec` from
the same `PlaybackInfo` response it already fetches, and reports
`supportsBurnIn: false` for the three image-based subtitle codecs
(`hdmv_pgs_subtitle`, `dvd_subtitle`, `dvb_subtitle` - `IMAGE_SUBTITLE_CODECS`).
The player's subtitle `` (`views/player/Player.ts`) shows these
as disabled options labeled "(image, non disponible)" rather than hiding
them outright - the track still exists and is still listed, just not
pickable, so it's clear *why* rather than silently missing. This answers
the "is item 2 also PGS" question directly from the app itself instead of
needing another round of server-log archaeology, and prevents anyone
from picking a subtitle that's guaranteed to break playback the same way
again, for any item, without needing to know any of this history.
**Round 6: a third item, same failure, `supportsBurnIn` said it should
have worked - and finally, why chasing the message by hand kept failing.**
A YouTube-downloaded video (AV1 video, `mov_text` subtitle - genuinely
text, `supportsBurnIn` would report `true`) hit the exact same 500. Not
PGS this time - a third distinct cause. What finally moved this forward
wasn't the exception message (still never successfully captured by hand,
across several attempts at pointing at the right log line or the
browser's Network tab) but a detail that surfaced almost by accident:
Emby's own access log recorded the failing response as `Content-Type=
text/plain, Content-Length=5190` - a real, several-KB error body really
was being sent every time, it just never survived being reported back
here. `hls.js`'s own error object only ever exposes the generic HTTP
reason phrase (`"Internal Server Error"`), never the actual response
text; Emby's live log view apparently doesn't reliably surface the
exception's headline message either, at least not in whatever's easy to
copy from it. Both dead ends, independently, on every attempt.
Fixed at the root instead of trying a fourth way to ask for the same
missing text by hand: `EmbyClient.fetch` - the one function every single
Emby call in this app goes through, `fetchJson`'s JSON API calls and
`proxyResponse`'s streaming/image proxy alike - now logs Emby's status
and full response body itself, straight to `docker logs`, whenever a call
fails. Reads the body via `.clone()` specifically so the real caller
(whichever one it is) still gets an untouched, fully readable response of
its own - a `Response` body can only be consumed once, and this had to
not break either of the two very different ways this app's callers read
one. `pathAndQuery` is logged with `api_key` redacted, matching
`logStreamRequest`'s existing concern - this can be called with Emby's
own `TranscodingUrl` verbatim, which can carry one. Only failures get
logged at all - a deliberate choice consistent with how the rest of this
app's logging already works (`Logger.party`/`Logger.stream` also skip
routine/unchanged traffic) - so this adds real, previously-invisible
detail on exactly the calls worth seeing, and silence on the thousands
that don't need it.
The next 500 on this or any future item gets its real cause into
`docker logs` directly, without another round of trying to hand-copy it
out of a browser or Emby's own log viewer.
### The actual fix: subtitles rendered client-side, not burned in by Emby
Six rounds in, the pattern across every failure was the same shape: this
app's own request was never the problem (confirmed each time - the
override reached Emby correctly, `getPlaybackOptions` already correctly
told PGS apart from real text), but *something* about asking Emby to
burn a subtitle into the video itself kept finding a new way to fail -
missing fonts, a format `libass` fundamentally can't burn (a bitmap, not
text), then "Error starting ffmpeg" outright on a *third*, otherwise
unremarkable file with neither of those problems. Three failures, three
different causes, all downstream of the one thing every attempt had in
common: asking for a real-time video re-encode. Fixing this by finding
and patching every way ffmpeg could fail was an open-ended list; not
needing ffmpeg to do this at all closes the whole class at once.
Subtitles are now delivered as their own resource and rendered by the
browser's native `` element, not encoded into the video:
- `CatalogService.proxySubtitle` (new) asks Emby to convert the track to
WebVTT - `GET /Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/
Stream.vtt`, a real Emby endpoint their own web client already relies
on for exactly this, entirely separate from the transcode pipeline.
Needs its own `PlaybackInfo` round trip just for `MediaSourceId` (the
video's own negotiation doesn't share this with a ``'s
independent request). `applyPlaybackOverrides` lost its
`SubtitleStreamIndex`/`SubtitleMethod=Encode` branch entirely - nothing
about the video request needs to know a subtitle was even picked
anymore.
- `getPlaybackOptions`'s per-track flag was renamed `supportsBurnIn` →
`isTextBased` - same underlying fact (image vs. text codec,
`IMAGE_SUBTITLE_CODECS`), but the name shouldn't keep describing a
mechanism (burning in) this app no longer uses. A bitmap subtitle still
has no text for Emby to hand back as WebVTT either, so it's still
correctly disabled in the picker - the *reason* just changed.
- `views/player/Player.ts`'s subtitle `` no longer reloads the
video at all (`reloadWithCurrentOptions`, still used for audio/quality,
never touches subtitles now) - `applySubtitleTrack` just removes
whatever `` exists and, for a real pick, adds a fresh one
pointing at `subtitleUrl(itemId, index)`, then sets
`video.textTracks[0].mode = 'showing'` **synchronously**, right after
appending. Deliberately not waiting on the `` element's own
`"load"` event for that: verified against a real browser that it
doesn't fire reliably at all (a known, general `` quirk, not
specific to this app or this fix) - which would have left a
subtitle silently never shown, the exact symptom the *first* round of
this whole saga was originally about. The TextTrack object itself is
registered in `video.textTracks` the moment the element is connected,
before its content has actually loaded, and a browser honors `.mode`
being set that early regardless. Re-applied on every video (re)load,
not just when the subtitle picker itself changes, so switching audio or
quality - which does still reload the video - doesn't drop whatever
subtitle was showing.
Verified against the real compiled bundle: picking a text track adds
exactly one `` with the right URL and flips it to `showing`;
picking "Aucun" removes it and `video.textTracks` goes back to empty; an
image-coded track is present in the list but disabled, exactly as
`isTextBased: false` says it should be; changing the audio track reloads
the video but the previously-selected subtitle's `` survives it,
same element, same `src`, still `showing`.
## 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.
**Login and basic usage are now confirmed for real** — updated once actual
bug reports started coming from real usage rather than mocked testing
(overlay/pause-request feedback, the series-detail request, and the
track-selection bug above, which is only reachable once a stream is already
playing). That means the following are confirmed working end to end against
this real server, not just reasoned about, even though none were checked in
isolation: response field names (`AccessToken`, `User.Id`/`Name`, item
`Id`/`Name`/`Overview`/`ProductionYear`/`Type`, `Views`' `CollectionType`,
`Shows/{id}/Seasons`/`Episodes`, `Items/Latest`'s plain-array shape,
`PlaybackInfo`'s `MediaStreams`), the `X-Emby-Authorization`/`X-Emby-Token`
header format, `rewritePlaylist` against a real Emby-generated playlist, and
`PlaybackInfo` → `TranscodingUrl` → HLS producing a real watchable stream.
None of this was independently exercised - it's inferred from "the features
built on top of it work in practice, and nobody's reported the classes of
failure that would follow from any of it being wrong" - worth keeping in
mind if something in this area breaks in a way that doesn't fit whatever
specific bug is being chased at the time.
**Genuinely not yet 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).
- **Player track selection** (`AudioStreamIndex`/`SubtitleStreamIndex`
actually taking effect) — see the section just above; a fix is in place,
reasoned from how Emby's API is known to work, but not yet re-tested for
real.
- Whether `TranscodingUrl` is reliably present for every source type (a
direct-play-eligible source might not transcode at all, which `hls.js` on
the client wouldn't handle — see Known limitations), and whether it's
always HLS.
- **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 (nobody's reported clicking it yet either way).
## 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 (one plain-text link per library from `GET /api/catalog/libraries`
— no icons, no emoji prefix, per an explicit earlier request) + a
`#mainContent` element the router (`router.js`) swaps on every navigation.
`GET /api/catalog/libraries` itself only ever returns libraries whose
`CollectionType` is `movies`/`tvshows`/`music`/`musicvideos`
(`VISIBLE_COLLECTION_TYPES` in `server/src/index.js`) — Emby's `/Views` can
return all sorts of things this app has no UI for at all (boxset
collections, playlists, live TV, photos, books, a mixed-content folder
with no `CollectionType`), and filtering once at the source means the
sidebar, the home page's per-library carousels, and direct navigation to
`/library/:id` are all restricted the same way without repeating the
check in each place — a stale link to a since-hidden library's id just
renders `library.js`'s existing "Bibliothèque introuvable" state, since
it looks the id up in this same filtered list. `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 (the live lobby subscription, not a fetch — see "Live
lobby, host handoff, no join codes" above) plus 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`/`musicvideos` all get a tile
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
and music videos as themselves, TV as Series tiles); `music` gets a tile
grid too, just of artists (`GET /api/catalog/libraries/:id/artists`) —
see "Music: participative playlist" below for the rest of that hierarchy.
- **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 —
`WATCH_PARTY_TYPES` in `server/src/index.js`, which had to grow a
`MusicVideo` entry once `musicvideos` became a visible library type,
otherwise every tile in it would render with no way to start a party at
all; 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.
Bug: "Voir les épisodes" used to render as a bare, default-blue link,
much less visible than the other two buttons next to it. The global
`button {}` rule (`app.css`) that gives "Lancer une watch party" its
solid green look is tag-scoped, so it never applied to "Voir les
épisodes" — the one `.tileBtn` that's an `` (a plain hash link) rather
than a ``. "Voir sur Emby" avoided the same fate only because it
additionally carries `.tileBtnLink`, which explicitly sets a background/
color for exactly this reason. Fixed by moving the solid-button look onto
`.tileBtn` itself, tag-agnostic, with `.tileBtnLink` now overriding just
the two properties that make it read as the secondary action. This also
happens to be the semantically correct fix, not just a visual patch: for
a Series tile "Voir les épisodes" *is* the primary action (a Series is
never itself directly playable, so the two buttons never compete for
primacy on the same tile) - it should look like one.
- **`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 `