269 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, TypeScript (compiled by tsc).
Proxies 100% of Emby access (catalog, images, HLS/audio
streaming) and owns party/room state + the realtime sync
channel - see "Auth and Emby access" right below for why it's
everything, not just party coordination, and "TypeScript and a
service-oriented architecture" further down for the current
file-by-file layout (one singleton service class per feature).
client/ Frontend, TypeScript, esbuild-bundled to one app.js + one
app.css (client/dist) - no framework. Talks only to server/,
never to Emby directly (api/embyApi.ts and api/party.ts are
both just fetch/Socket.IO wrappers around this app's own
/api/* routes). One folder per view, each colocating its own
.ts/.html/.css - see "TypeScript and a service-oriented
architecture" further down.
Both projects predate this exact shape - see git history and "TypeScript and a service-oriented architecture" further down for the plain-JS -> TypeScript rewrite itself, including why (a deliberate, requested choice, not a default) and what changed structurally.
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/loginon our own backend — never to Emby directly. - Our backend (
server/src/embyAuth.js) calls Emby'sPOST /Users/AuthenticateByNameitself, usingEMBY_SERVER_URLfrom 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 anhttpOnlycookie (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.jsis 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.m3u8and.../rawroutes 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. SeerewritePlaylistinembyProxy.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/seekedon 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_SECONDSinclient/js/views/player.js) — avoids fighting small timing noise with constant re-seeks.
- Event-driven: every
- 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:
- Server-side: dead connections never got cleaned up. A raw
wsserver 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 firews's'close'event, so thePartyMember's socket reference (and everything it closes over) would sit in memory forever, and the party would never get cleaned up by anything (removeIdlePartiesonly looks atlastActivityUtc, 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. - Client-side: leaving the player any way other than its own "Quitter"
button leaked everything.
router.jsonly ever didcurrentRoot.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 heartbeatsetInterval, its realtime connection, and itshls.jsinstance all running against a detached<video>element indefinitely - for as long as the tab stayed open. Fixed by havingroute()handlers optionally return a cleanup function;router.jscalls whatever the previous view returned right before rendering the next one, regardless of how navigation happened.player.jsreturns one that clears the heartbeat, destroyshls, disconnects the socket, and removes the onedocument-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'sconnectLobbyjoins a"lobby"Socket.IO room (subscribeLobby→server/src/socketHandler.js) and gets a fresh snapshot on subscribe, then again every timebroadcastLobby(io)runs - which is now wired into every place party membership changes: creating a party, joining one (both inindex.js's REST handlers), and leaving (applyLeave, used by both the socketleave/disconnecthandlers and the REST leave endpoint, so this - and the host-handoff notification below- only has to be right in one place).
home.jsreturns 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.
- only has to be right in one place).
- 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
createdUtctimestamp the server includes once (home.js'ssetIntervalre-renders every.elapsedlabel 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 apartiespush with acreatedUtctwo minutes in the past and waiting rendered "2:06", not a static value. - No join codes.
PartyManagerno 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/joinreplaces the old:joinCoderoute. - Host handoff is live, not just correct in the data model.
PartyManager.leavealready 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.applyLeavenow emits a personal"hostChanged"(to that one socket, not the room) when this happens;player.js'sbecomeHost()- the same functioninit()calls when you already are the host - unlocks the controls live, no reload needed.becomeHostis written to tolerate being called more than once safely:addEventListenerwith the same function reference is a no-op if already attached, and the heartbeatsetIntervalis explicitly guarded (if (!heartbeat)) sincesetIntervalhas 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
LobbyPartyDtothe 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.tsfalls back to the originalcreatedUtcbehavior for those). The host is already authoritative for this inside their own party's room ("playstate"); the lobby snapshot just reads the same tracked valueSocketGatewayalready had, rather than a party-level position needing to be kept in sync separately.broadcastLobbygained 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.advanceQueuealready 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 fakedpartiespush: a paused party's label held exactly still across a real wait; a playing one, and a music party still on the oldcreatedUtcpath, 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'srenderMembers()— already responsible for the text member list — now also rebuilds#presenceOverlay, one circle per member. Each circle shows the first two characters ofuserName, 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: .75on.avataris 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 sameauthsingletonembyApi.jsalready populates at login/session-restore) rather than byuserId— consistent with the rest of the client already treatinguserIdas a server/protocol-internal detail (seehostChanged'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
.avatarClickableand a click handler that calls the party connection'srequestPause()(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_MSinplayer.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#ff9800border 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'suserIdagainst each circle'sdata-user-id, both clearing automatically afterPAUSE_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). .followerNoticepicked up amargin: 0it was missing: it's a<p>, and for an absolutely positioned elementbottomplaces the margin edge, so its default ~1em user-agent margin was quietly rendering it 13px higher than its declaredbottom: 24pxever suggested. Harmless on its own until.pauseRequestBannerneeded to sit a specific gap above it — found by measuring both rects and seeing the gap read as 2px instead of the ~15px the twobottomvalues 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 <track> 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 <select> (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 <track> 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 ownPlaybackInforound trip just forMediaSourceId(the video's own negotiation doesn't share this with a<track src>'s independent request).applyPlaybackOverrideslost itsSubtitleStreamIndex/SubtitleMethod=Encodebranch entirely - nothing about the video request needs to know a subtitle was even picked anymore.getPlaybackOptions's per-track flag was renamedsupportsBurnIn→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<select>no longer reloads the video at all (reloadWithCurrentOptions, still used for audio/quality, never touches subtitles now) -applySubtitleTrackjust removes whatever<track>exists and, for a real pick, adds a fresh one pointing atsubtitleUrl(itemId, index), then setsvideo.textTracks[0].mode = 'showing'synchronously, right after appending. Deliberately not waiting on the<track>element's own"load"event for that: verified against a real browser that it doesn't fire reliably at all (a known, general<track>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 invideo.textTracksthe moment the element is connected, before its content has actually loaded, and a browser honors.modebeing 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 <track> 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 <track> 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/SubtitleStreamIndexactually 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
TranscodingUrlis reliably present for every source type (a direct-play-eligible source might not transcode at all, whichhls.json 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.jsrenders the persistent chrome once per login: a sidebar (one plain-text link per library fromGET /api/catalog/libraries— no icons, no emoji prefix, per an explicit earlier request) + a#mainContentelement the router (router.js) swaps on every navigation.GET /api/catalog/librariesitself only ever returns libraries whoseCollectionTypeismovies/tvshows/music/musicvideos(VISIBLE_COLLECTION_TYPESinserver/src/index.js) — Emby's/Viewscan 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 noCollectionType), and filtering once at the source means the sidebar, the home page's per-library carousels, and direct navigation to/library/:idare all restricted the same way without repeating the check in each place — a stale link to a since-hidden library's id just renderslibrary.js's existing "Bibliothèque introuvable" state, since it looks the id up in this same filtered list.router.jswas 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 duplicatehashchangelisteners — 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'scollectionType.movies/tvshows/musicvideosall get a tile grid (GET /api/catalog/libraries/:id/items,Recursive=false— Movies and music videos as themselves, TV as Series tiles);musicgets 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_TYPESinserver/src/index.js, which had to grow aMusicVideoentry oncemusicvideosbecame 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 aembyUrlthe backend builds fromEMBY_SERVER_URL+ the session'sserverId).:hover/:focus-withinreveal 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.tileBtnthat's an<a>(a plain hash link) rather than a<button>. "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.tileBtnitself, tag-agnostic, with.tileBtnLinknow 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 justtoItemDto'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 frombutton.linkliketo.linklikeso the new "Voir sur Emby"<a>picks it up too — there's no globalareset inapp.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
Chat/reactions and OIDC/SSO remain out of scope - see Goals/Non-goals at the top. Music was in this list for a while (see git history) - it's implemented now, see its own section below.
Server-side logging
Requested once the app was running for real on the user's own server:
visibility into what's actually happening, from docker logs/journalctl,
without needing to reproduce a bug through the UI first. server/src/logger.js
is deliberately tiny - partyLog(message) ([watchparty:party]) and
streamLog(message) ([watchparty:stream], added a bit later for the
streaming route specifically - see "Player track selection" above), both
plain console.log with an ISO timestamp, plus a who(userId, userName)
formatter shared by every party-lifecycle call site - no logging library,
consistent with this app's general bias against dependencies it doesn't
need.
Covers the party's business lifecycle end to end, called from wherever the
state actually changes so every caller is covered without repeating the
check - server/src/partyManager.js's create/join/leave (which also
covers a host handoff or the party being destroyed, both of which fall out
of leave)/advanceQueue/removeIdleParties, plus
server/src/socketHandler.js's playstate and requestPause handlers,
since those two mutate member state directly rather than through a
PartyManager method.
The one thing this needed real care over: the host's player re-sends its
current play/pause state every 3 seconds regardless of whether anything
changed (the heartbeat - see "Realtime transport" above), on top of firing
once per actual play/pause/seeked event. Logging every "playstate"
message as it arrives would mean one log line every 3 seconds for every
active party, forever - noise that would drown out everything else within
minutes on a server with even a couple of parties running. The handler
instead compares the incoming isPaused against the member's previous
value and only logs on an actual flip, silently updating position on every
other message - so a party generates exactly one "playstate" line per
real pause or resume, never one per heartbeat tick.
Verified directly against the real shipped modules (not reimplemented
mocks) - see session history: a scripted create/join×2/advanceQueue
(twice — once real, once a no-op past the end of the queue, correctly
silent)/leave×3 (plain, host handoff, final-member destroy)/idle-sweep
sequence against the real partyManager.js produced exactly the expected
line for every step, in the expected order; a fake io/socket pair
driving the real socketHandler.js through a start → 5 unchanged
heartbeats → pause → 3 unchanged heartbeats → resume sequence logged
exactly 3 "playstate" lines (not 9) at exactly the right points, and a
throttled repeat "requestPause" produced no second "pauseRequested"
line - all assertions passed, counted programmatically rather than
eyeballed.
Music: participative playlist
The last deferred piece from the original UI spec, built once asked for directly. Three things needed designing before any code: the permission model for the shared queue (asked, not assumed - see session history), how audio playback fits the existing party/socket infrastructure, and how "browse and add a track" works without leaving the player.
Chosen model: free add, vote-to-skip, no host for playback. Anyone in
the party can add a track to the shared queue at any time. Anyone can
pause/resume directly (reversible, low-stakes - unlike video's host-only
"playstate"). Skipping the current track needs a strict majority vote
of currently connected members - the one action asked to be gated, since
unlike adding (purely additive) or pausing (trivially undone), skipping
throws away where the party is now. This is a genuinely different shape
of shared state than video's "one host drives, everyone follows" - not a
size difference, a different model - which is exactly why it's a separate
service (server/src/musicQueueService.js, MusicQueueService) rather
than more branches inside partyManager.js/socketHandler.js's existing
video-shaped logic.
Data model and server-authoritative timing
Party gained a mediaType: 'video' | 'music' field
(PartyManager.create's new last argument, inferred server-side in
POST /api/parties from the clicked item's Emby Type - 'Audio' means
music, everything else already handled means video - the client never has
to know or say which). A music party's party.music object
({queue, current, skipVotes, advanceTimer}) is entirely separate from
the fixed pre-built party.queue/queueIndex video parties use - a music
queue is live and mutable, not something computed once at creation.
No single client is "the position" for music the way a video host's
heartbeat is. Instead, party.music.current.startedUtc (a wall-clock
timestamp) plus the track's known duration is the only state that exists -
getElapsedSeconds() computes position on demand
(Date.now() - startedUtc, or a frozen pausedElapsedMs while paused),
never ticked or stored. This is also what makes auto-advance possible
without any client "reporting" a track ended (which, with several clients'
players all finishing within milliseconds of each other and no single
host, could race): _scheduleAdvance sets one setTimeout for exactly
when the current track's duration runs out, computed from the same
startedUtc. A track with no known duration just has nothing scheduled -
it relies on a manual vote-skip instead, same as it would if the timer
fired.
That timer is the one genuinely new leak surface this feature adds, given
how much this project has already had to fix real memory leaks (see
"Realtime transport" above) - _clearTimer runs before every reschedule
and from a new destroy(party) hook wired into both of
partyManager.js's existing party-destruction paths (leave's last-member
case and removeIdleParties' sweep), so a torn-down party can never leave
a timer firing against state that no longer exists in PartyManager's map.
MusicQueueService needs to broadcast from inside that timer's callback,
which runs with no socket/request in scope at all - solved with
attachIo(io, onTrackChanged), called once at server startup
(index.js, right next to where attachSocketServer itself is wired up),
the same "give the singleton what it needs once, up front" shape
PartyManager's own singleton already has. onTrackChanged is wired to
broadcastLobby(io) specifically so a party's home-page card (preview
image/name) stays current even when the timer, not a person clicking
anything, is what changed what's playing.
Catalog: Artists → Albums → Tracks
Three more dedicated Emby endpoints, same reasoning as Shows → Seasons → Episodes: guessing folder structure via generic recursive /Items calls
is fragile, a real endpoint per level isn't. GET /Artists?ParentId=...
for a library's artists, GET /Users/{id}/Items?ArtistIds=... &IncludeItemTypes=MusicAlbum for an artist's albums, GET /Users/{id}/Items?ParentId=<albumId>&IncludeItemTypes=Audio for an
album's tracks. toItemDto grew durationTicks/indexNumber/
artistName/albumName - additive, undefined for every other item type,
so nothing existing had to change shape. WATCH_PARTY_TYPES grew
'Audio' (a track can start a party) - MusicArtist/MusicAlbum stay
un-playable, drill-down only, joining Series in tiles.js's
DRILL_DOWN table (generalized from a single seriesHref special case to
a small per-type lookup once there were three, not two, drill-down
targets - /artist/:id → artist.js, /album/:id → album.js, each a
smaller version of the library-grid/episode-list shape already used
elsewhere).
Streaming: the same route, one hint
Audio goes through the exact same /api/catalog/stream/:itemId/master.m3u8
route as video, not a parallel endpoint - Emby's PlaybackInfo negotiation
isn't video-specific, so the whole TranscodingUrl-first,
hand-built-fallback dance (see "Player track selection" above) applies
identically. The one real difference is the hand-built fallback's path
prefix (/Videos/ vs /Audio/), which the response doesn't reliably
reveal on its own and looking up would cost an extra Emby round trip for
something the client already knows - so the client tags the request
(streamUrl(itemId, {mediaType:'audio'}), audioStreamUrl() in
embyApi.js) and the route reads it back
(req.query.mediaType === 'audio'). The music player uses hls.js for the
same reason the video player does: most browsers have no native .m3u8
support at all, and there's no reason to assume Emby's negotiation
produces anything other than HLS just because the media is audio.
Player: not theater-mode, and why adding a track doesn't navigate away
renderPartyPlayer (route /party/:id, player.js) is now a small
dispatcher: it fetches the party once (both the video and music renderers
need mediaType before they can render anything at all) and delegates to
renderVideoPlayer or the new renderMusicPlayer
(views/musicPlayer.js). The dispatch itself can't be async despite
needing to await that fetch - route()'s contract needs its cleanup
function back synchronously (see router.js), and an async handler
would hand back a Promise instead, which router.js's typeof cleanup === 'function' check silently skips - reintroducing the exact leak class
this project already spent real effort fixing once. Solved with a mutable
box: renderPartyPlayer returns a wrapper immediately, which calls
whichever real cleanup function the fetch eventually produces - or nothing
at all, correctly, if the party navigates away before that fetch even
resolves (nothing was set up yet in that case - no socket, no timer,
nothing to leak).
Unlike the video player, the music player is not full-viewport/theater
-mode (no .playerWrap-style position:fixed) - there's no video image to
declutter around, so it renders as a normal page and the sidebar stays up.
That's also what makes "add tracks while listening" simple: rather than
navigating to a separate browsing page and losing the player's own state
(and having to thread "which party am I adding to" through a chain of
query params across three route levels - considered, rejected as needless
complexity for what browsing-with-context would have bought), the player
has its own small embedded Artist → Album → Track browser
(musicPlayer.js's loadMusicLibraries/loadArtists/loadAlbums/
loadTracks), reusing the exact same catalog endpoints, rendered inline
in a toggleable panel. Adding a track is REST
(POST /api/parties/:partyId/tracks, addTrackToQueue() in party.js),
not a socket event like voteSkip/musicPlaystate - deliberately, since
it needs to work from this same panel regardless of realtime-connection
state, and MusicQueueService.addTrack's own "queueUpdated" broadcast
(via the attachIo wiring above) reaches everyone actually connected
either way, so nothing about the realtime side needed a second code path.
A real bug found while testing, not just the mock's fault
applyMusicState (the "queueUpdated"/initial-render handler) used to
update the queue list and vote button after the current-track branch,
which calls loadTrack() - and loadTrack()'s hls.js setup can throw (a
bad manifest, hls.js failing to load from the CDN, any of it). Because
JavaScript doesn't roll back a function's earlier statements when a later
one throws, but does abort everything after the throw in the same call,
an exception there took the queue list and vote tally down with it even
though they have nothing to do with whether one specific track's audio
loaded - and worse, on the very first render, it escaped renderMusicPlayer
entirely into the dispatcher's .catch(), which alerts and navigates back
to /home, discarding the whole view. Fixed two ways: reordered
applyMusicState so the queue/vote/icon updates - which can't fail - run
before the current-track branch that can (mirroring player.js's
onItemChanged, which already updates the title/next-button before
calling loadVideo for exactly this reason), and wrapped loadTrack's
hls.js setup in its own try/catch so a failure there logs and stops,
rather than propagating out of the handler at all. Found by a test whose
own mock happened to be missing Hls.Events - which pointed at a real
fragility in the shipped code, not just an incomplete mock, so it's fixed
in musicPlayer.js itself, not worked around in the test.
Verified
Server-side, directly against the real shipped modules, not reimplemented
mocks (see session history): a scripted sequence against the real
musicQueueService.js alone - init auto-starts the first track, a second
add while one plays queues rather than jumping the queue, a two-vote
majority skips and clears the tally, pause/resume freezes and correctly
resumes elapsed time, a short real track auto-advances on its own via a
genuine setTimeout after its duration, and destroy() clears a pending
timer (18 checks, instrumented setTimeout/clearTimeout counting to
catch anything left dangling - same technique used for the client-side
router-cleanup leak earlier in this project). A second pass through the
real socketHandler.js + partyManager.js together (fake io/sockets)
confirmed voteSkip/musicPlaystate are no-ops on a video party, a
non-host member can pause and resume a music party's track directly, a
two-connected-member vote-skip needs exactly two votes, and the resulting
"queueUpdated" really reaches the party's socket room (7 checks).
Client-side, against the real running local server with mocked
fetch/Hls/io (see session history): the full library → artist →
album browsing chain renders correct tiles and drill-down links at each
level; starting a party from a track correctly creates one and navigates;
the dispatcher correctly renders the music player (not the video one) for
a mediaType:'music' party and still renders the video player when
mediaType is absent (back-compat); the now-playing header, vote tally
(computed against the live member list, matching musicQueueService.js's
own majority math), and queue list all render correctly and update
correctly on a "queueUpdated" push including a live track change (new
audio source loaded); play/pause and vote-skip clicks emit the right
socket events; the inline browse-and-add panel correctly walks
artist → album → tracks and POSTs the exact right payload shape to
/api/parties/:partyId/tracks; and the crash fixed above was confirmed
fixed by re-running the same broken-Hls.Events scenario through the
full dispatcher afterward - no alert, no navigation away, the queue/track
info render correctly regardless.
Not verified - the usual limit of testing without real Emby access:
whether /Artists/the album-tracks query really return what's assumed
here, whether a music item's PlaybackInfo/TranscodingUrl behaves the
same way a video's does (reasoned to, per "Streaming" above, not
observed), and the real audio quality/gapless-ness of playback through a
transcode. First things to check once this gets a real retest.
Music bar polish + catalog search
Four smaller, real-usage-driven fixes, once the music bar and the persistent-overlay redesign had actually been used for a while:
- Vote-skip's "X/Y needed" didn't update on a join/leave. The
majority threshold depends on the connected-member count, which
renderMembers(the"members"push) updates - but the label itself was only ever recomputed insideapplyMusicState(a"queueUpdated"push - an actual vote or track change). Two different events, each only refreshing half of what the label needs. Fixed by caching the lastMusicPublicState(lastMusicState) and factoring the label math into its ownupdateVoteSkipLabel(), called from both handlers now. - The listen party you're already in still offered "Rejoindre" on the
home page.
Home.ts's card rendering had no way to know "this one's mine" - fixed by checking each card'spartyIdagainstmusicBar/MusicBar.ts's owngetActiveMusicPartyId()(already tracked client-side, no server change needed) and swapping the button for an inert "En écoute" label on that one card. Video has no equivalent case: its player is a full-screen takeover, so you can't be looking at this list while inside one. - A progress bar for the music bar. Read-only, deliberately - see
MusicBar.html's own comment: there's no host to arbitrate a seek in this model, andMusicQueueServicehas no "jump to position" concept server-side at all today (a real protocol addition, not a UI-only change - out of scope for what was asked here). The fill's width is justaudio.currentTime / durationSeconds, ticked by the same interval that already updates the position text. - Search bars for the catalog pages (
views/library,views/artist,views/album). Client-side only, filtering whatever's already rendered/fetched rather than a server round trip per keystroke - a library page tops out at a few hundred items (getLibraryItems's ownLimit), comfortably inside "just filter the DOM" territory. Shared astiles.ts'swireSearchFilterso the three pages behave identically. The existing/api/catalog/searchendpoint (CatalogService.search, video types only, never wired into any UI) is a separate, unrelated feature - a cross-library global search, not asked for here - left as-is.
Verified against the real compiled bundle for all four: a "members"
push alone (no vote, no track change) updates the skip label; the home
page's own active party shows "En écoute" while every other card still
shows "Rejoindre"; the progress fill tracks a simulated currentTime
correctly; typing into a library's search box hides non-matching tiles
live and the "Aucun résultat" notice appears only once nothing matches.
TypeScript and a service-oriented architecture
A full rewrite, requested directly: both server/ and client/ are now
TypeScript, and the server is organized as one singleton service class per
feature rather than route handlers with inline logic. This is the largest
single change in this project's history - it touches nearly every file -
so this section covers the shape of it rather than every line moved.
Why this needed real decisions, not just "add types"
This app was "no build step" from the very first version, deliberately -
the client's plain ES modules loaded straight in the browser, the server
ran its .js directly. TypeScript can't compile itself away at
runtime - something has to turn .ts into .js before either project
can run - so "TypeScript" and "no build step" are mutually exclusive, not
a small addition to the existing setup. Two independent decisions followed:
- Server: plain
tsc. The server doesn't need bundling (Node resolvesrequire()s itself, there's no browser payload-size concern) - just type-stripping and down-leveling, which is exactly whattscdoes.server/distmirrorsserver/src's structure 1:1. - Client: esbuild. The client does need bundling (a browser
shouldn't have to make a dozen round trips for a dozen modules, and
there's no reason to hand-manage
<script type="module">ordering either) - esbuild was picked over a heavier tool (Vite, webpack) because this app's whole client is a couple thousand lines across a dozen files; esbuild's own built-in behaviors (bundling CSS reached from JS/TS imports into a sibling.cssfile automatically, atextloader for importing an HTML template as a string) cover everything needed with a ~30-line hand-written build script (client/build.mjs), not a config file for a tool built for much bigger apps. Both are consistent with this project's running bias against dependencies it doesn't need. - Both projects compile independently - the server's
tscnever sees client code and vice versa. A real shared-types package was considered and rejected as more structural commitment than this app's size justifies;server/src/types.tsandclient/src/types.tshave their own overlapping copies of the DTO shapes instead (Party, ItemDto, the music types, ...) - a normal, accepted trade-off for a two-small-projects setup like this one. npm run devinserver/usestsx(a single extra dev dependency) to run straight from.tswith no separate compile step while iterating;npm run build(tsc) is what actually ships, and whatserver/Dockerfile's build stage runs. The client'snpm run watch(esbuild's own watch mode) plays the same role there.
Server: services, not route handlers with logic inline
The old index.js had ~15 route handlers with their actual logic - Emby
queries, DTO shaping, override-forcing - written directly inside the
app.get(...) callback. server/src/index.ts is now a thin wiring layer
only: every route reads the session, calls exactly one service method,
and returns what it gets back. The services, one singleton class per
feature (same shape PartyService/MusicQueueService already had before
this rewrite - this generalizes that pattern, it doesn't invent it):
AuthService(fromembyAuth.js) - login + the in-memory session store.CatalogService(new - previously inline inindex.js) - every Emby browsing/streaming call and the DTO shaping (toItemDto,applyPlaybackOverrides) that used to be bare functions at the bottom of the file. The single largest extraction.PartyService(renamed fromPartyManager) - unchanged behavior, now fully typed (see "A real type bug caught during the port" below).MusicQueueService- unchanged behavior, ported as-is (it was already the newest, already-service-shaped code).SocketGateway(fromsocketHandler.js) - the realtime protocol. Not stateful itself (no singleton state, unlike the four above) but kept as a class for the same reasonLogger/EmbyClientare: shape consistency acrossserver/src, not because every one of them strictly needs to be a class.EmbyClient(fromembyProxy.js) - low-level Emby HTTP + response proxying. Deliberately not called a "service": it has no state and isn't a feature on its own, just the shared plumbingCatalogService's routes are built on.
Error handling got a real pass while this code was being touched
anyway. Express 4 doesn't catch a rejected Promise from an async
route handler on its own - an unhandled rejection there would either
crash the process or hang the request, depending on Node version. Every
route is now wrapped in a small asyncRoute() helper
(index.ts) that forwards a thrown/rejected error to a single
error-handling middleware at the bottom of the file. CatalogService
methods throw a CatalogFetchError (carrying its own HTTP status -
e.g. getRawItem preserves Emby's real 404 instead of a blanket 502,
which the old code did per-route by hand) rather than touching an Express
response directly, so the same service method works whether it's called
from a route that has a res handy or (in principle) from anywhere else.
This wasn't a change asked for - it fell out of centralizing the routes
into services, and it's a real robustness improvement over the old
per-route if (!embyRes.ok) { res.status(...).json(...); return; }
repeated fifteen times with no handling at all for a request that
outright throws.
Client: one folder per view, colocating .ts/.html/.css
Each view is now a small folder (src/views/<name>/<Name>.ts +
.html + .css) instead of one .js file with everything - including
the markup, as a big template literal - inline. The static shell of a
view's markup lives in its .html (imported as a raw string via
esbuild's text loader - see global.d.ts's declare module '*.html');
dynamic, per-item content (a tile grid, an episode list, a music queue)
still gets built as a template-literal-returning function inside the
.ts, same as before - only the outer, unchanging shell moved out.
Genuinely shared CSS (the tile grid, the presence-avatar circle, the
series/album detail header pattern, track-list rows) lives in small files
under src/styles/, imported by every .ts that needs it; src/styles/base.css
holds the truly global primitives (the theme's CSS variables, button/
input/.linklike, .pageHeader) and is imported exactly once, by
app.ts.
This is deliberately not pushed to the point of zero duplication or
class renaming across views that happen to share a visual pattern (the
series and album detail headers, for instance, keep their own
seriesHeader/albumHeader + cover-image classes, sharing only the
inner seriesInfo/seriesYear/seriesOverview block via
styles/detail.css) - renaming shared classes purely for tidiness would
have meant touching working, already-tested markup and CSS in lockstep
across files with no functional benefit, for no reason better than
aesthetics, in a change already large enough to review.
Player.ts keeps the same internal shape it had before this rewrite: one
file holding both the /party/:id route's dispatcher (renderPartyPlayer,
exported, registered in app.ts) and the video-specific renderer, which
imports renderMusicPlayer from the sibling musicPlayer folder - the
dispatcher pattern (fetch once, pick a renderer, hand back a synchronous
cleanup wrapper - see "Player: not theater-mode..." above) predates this
rewrite and didn't need to change shape, just types.
A real type bug caught during the port
Porting SocketGateway's "members" broadcast, it was tempting to reuse
the same PublicMember interface PartyService.toPublicState()'s REST
response uses. They're not the same shape: the REST response's
members[] includes isPaused/positionTicks per member; the socket
broadcast never did (a follower's player only ever reads "playstate"
events for position, never the member list, for that - see
SocketGateway's original JS, which built a visibly smaller object
inline). Reusing the wrong interface would have type-checked fine (a
smaller object being assigned into a type asking for more fields is
exactly the class of mistake a structural type system like TypeScript's
does catch, but only if the two shapes are actually kept distinct) -
caught by comparing the ported code against the original JS line by line
rather than trusting the port "looked right", and fixed by giving the
socket broadcast its own SocketMemberDto type. Worth noting because
it's a fairly direct demonstration of what "clean TypeScript" is actually
for here: not decoration, a mistake this port itself introduced and then
caught before it shipped.
A real Express+TypeScript friction point, resolved once
tsconfig.json for both projects enables noUncheckedIndexedAccess - a
stricter-than-strict flag that treats every indexed access
(arr[i], and critically, Express's req.params.foo, which is typed as
an index signature) as possibly-undefined, to actually catch
out-of-bounds access. This is genuinely useful (PartyService.advanceQueue's
party.queue[party.queueIndex] is exactly the kind of access this
catches if the index math is ever wrong) but makes every single
req.params.xxx read in index.ts a type error, since Express's own
types don't know a given route only ever matches with that param
present. Resolved once with a param(req, name) helper in index.ts
(asserts the value as string - a matched Express route with a required,
non-optional :name segment always has it, which none of these routes'
patterns violate) rather than either scattering non-null assertions at
every call site or writing out a params-generic interface per route for
15 routes.
Verified
Both projects type-check cleanly in strict mode
(npm run typecheck/tsc --noEmit, no errors) - not just "compiles",
which esbuild would do even with type errors present, since it
transpiles without checking; the client's own separate tsc --noEmit
step is what actually guards against that gap.
Server: every unit/integration check written earlier for the JS version
(party lifecycle logging, the music queue's timer/vote/pause behavior
including real setTimeout-based auto-advance, the socket-level
integration through SocketGateway+PartyService+MusicQueueService
together, the library-type filter, the playback-override-forcing logic)
was first re-run ad hoc against the compiled server/dist output to
confirm the port itself changed nothing behaviorally, then formalized
into a real, committed suite: server/test/*.test.ts (one file per
service - musicQueueService, partyService, socketGateway,
catalogService), using Node's built-in node:test + node:assert/strict
(zero new dependencies) and run straight against the TypeScript source via
tsx --test (npm test) - no compile step, no separate test-runner
dependency. A dedicated tsconfig.test.json (extends the base config,
overrides rootDir to . and adds test/**/*.ts to include) lets
npm run typecheck cover the test files too without pulling them into
dist/ when npm run build (plain tsc, still scoped to src/) runs.
The compiled server was also actually started (node dist/index.js) and
confirmed listening cleanly, both directly and via tsx for the dev path.
Continuous integration
.forgejo/workflows/publish-docker.yml gained a test job that runs
before publish (needs: test): server npm run typecheck + npm test,
then client npm run typecheck + npm run build. It runs in a
node:20-alpine container - a separate container from publish's
docker:27-cli, since that one has no Node at all (that's also why both
jobs clone sources by hand with git init/fetch/checkout rather than
actions/checkout, which is a JS action and needs Node to run). The two
jobs don't share a filesystem, so each clones independently; publish
only starts once test succeeds.
A real failure on the very first two runs, caught by actually watching
the pipeline rather than assuming green: "test": "tsx --test test/**/*.test.ts" worked perfectly locally and failed on the runner with
Could not find '.../test/**/*.test.ts', as if the literal glob string
had been handed to Node as a filename. It had - node --test gained
native glob-pattern CLI arguments only around Node 21/22, and this dev
machine's global node is v22, while the CI job (deliberately pinned to
node:20-alpine, matching server/Dockerfile's runtime image) is v20 -
same script, two Node majors, one of which silently doesn't glob-expand
the argument at all and just looks for a file with * characters in its
literal name. Fixed by listing the four test files explicitly in the
script instead of trusting a glob to be interpreted the same way on every
Node version this project targets (engines.node is >=18) - a
one-time list-maintenance cost in exchange for not depending on a CLI
feature whose availability turned out to vary across the exact two Node
installs this project actually runs on.
Client: since esbuild bundles everything into one app.js (no more
individually-importable view modules the way separate <script type="module"> files were before), testing moved from "import one view
and call it directly" to driving the real, compiled bundle through
its actual boot → login → router → view chain, with fetch/window.io/
window.Hls mocked and real clicks/hash-navigation - arguably a more
realistic test than the old per-module approach, not just an adaptation
forced by the bundler. Confirmed this way: login, the sidebar, home's
carousels, the movies library, a video party (presence, host controls,
leave), the full music hierarchy (library → artist → album), a music
party (presence, vote-skip, pause toggle, the inline browse-and-add
panel posting the right payload), and the series page - all through
client/dist's actual output, no thrown errors anywhere in the sequence.
Not verified: an actual docker build of the new multi-stage
Dockerfile - this dev environment has no Docker installed at all. The
Dockerfile's build stage runs the exact same npm run build commands
already confirmed working locally for both projects, just inside
node:20-alpine instead of this machine, so the main remaining risk is
something environment-specific (a native dependency, a Node version
mismatch) rather than the build commands themselves being wrong - still,
a real build is the first thing to try after this lands, before assuming
the Dockerfile is right.
A .hide specificity bug: one CSS rule silently beating another
The player's settings gear (audio/subtitle/quality) stayed permanently
open regardless of the hide class ever being toggled on it. Root cause:
.hide { display: none; } (styles/base.css) and .settingsPanel { display: flex; ... } (views/player/Player.css) are both single-class
selectors - equal specificity - so when an element carries both
classes, the cascade falls back to source order for whichever property
they both set, and esbuild's CSS-from-JS bundling had Player.css
(imported inside a view module) land after base.css (imported once,
globally, at the top of app.ts) in the final app.css. The later rule
wins a specificity tie, so .settingsPanel's own display: flex always
overrode .hide's display: none - not a bug in the toggle logic at all,
the class was being added and removed correctly the whole time, it just
had no effect on this one element.
Fixed at the source of the utility class, not the one call site:
.hide { display: none !important; }. !important beats a same-
specificity non-!important rule regardless of bundling/source order, so
this closes off the entire bug class - any future component CSS that
sets display for its own visible state (several already do - tiles,
the shell, every carousel) is safe to combine with .hide without
silently losing to bundling order the way .settingsPanel did. Verified
by rebuilding and driving the real compiled bundle (mocked
fetch/window.io/window.Hls, same methodology as the TypeScript
rewrite above): before the fix, getComputedStyle(settingsPanel).display
read "flex" even with the hide class present; after, it correctly
reads "none" until the gear button is clicked and "none" again once
dismissed.
Music bar: an overlay you keep browsing under, not a page
The music player was originally its own route (/party/:id, the
now-deleted views/musicPlayer/) - deliberately not theater-mode, but
still a full page swapped into #mainContent like any other view (see
"Music: participative playlist" above). That was fine for playback and
for a small embedded "browse and add" panel, but it meant navigating
anywhere else - the sidebar, an album to queue up more tracks, home -
tore the whole thing down: router.ts calls a route's cleanup and
replaces #mainContent on every hash change, and this view's cleanup
called leaveParty. There was no way to be "in" a listen party and also
browsing the rest of the app at the same time.
Fix: move the player out of the router entirely. musicBar/MusicBar.ts
is a Shell-level singleton - mounted once by Shell.ts into a #musicBar
element that's a sibling of #mainContent, never touched by route
changes - rendered as a fixed bottom bar (position: fixed; bottom: 0,
below the video player's .playerWrap z-index so a video takeover still
correctly covers it, though that's now moot in practice - see below).
Because it's a singleton rather than something re-rendered per visit, its
lifecycle isn't the render()-returns-a-CleanupFn shape every route uses -
it never registers as a route at all. Instead it exposes three plain
functions: initMusicBar(root) (wire the static DOM once, at boot),
joinMusicParty(partyId, party) and leaveMusicParty() (attach/detach a
live party on top of that already-wired DOM, called from wherever a party
is actually joined/left instead of from a route's own init/cleanup).
views/player/Player.ts's dispatcher (renderPartyPlayer) is the one
place every "start/join a party" call site already funnels through
(home's live list, a tile's "Lancer une watch party", an album's "Écouter
ensemble" all just navigate('/party/:id') and let this route sort out
what kind it is), which made it the natural single choke point for two
new rules instead of touching every call site individually:
- Music: don't render a page at all - call
joinMusicPartyandnavigate('/home')right back (a no-op bounce when the user was already on home, which is true for every reachable call site except Album's - see below). No cleanup function is ever set for this branch;musicBar/MusicBar.tsmanages its own lifetime from here on. - Video: call
leaveMusicParty()unconditionally (a harmless no-op if nothing's active) beforerenderVideoPlayer. This is the "playing something else exits the listen party" rule, asked for directly - one call, covers every entry point that can lead to a video party, since they all pass through this same dispatcher first.
views/album/Album.ts is the one place that needed its own change beyond
the dispatcher: it's the only per-track "start a party" UI left now that
the music player's own inline browse panel is gone (browsing happens by
just navigating there normally, which is the whole point). Each track's
button does double duty - start a new party, or add to whichever one is
already active - decided by getActiveMusicPartyId() at click time,
not just when the row was rendered. That distinction matters: the button
label is only ever as fresh as the last render (harmless staleness - it
might still say "Écouter ensemble" for a few seconds after a party
started from elsewhere), but the actual behavior on click is always
correct because it re-checks live state at the moment it matters, not the
snapshot from render time. After a successful click, every button in the
list is relabeled in place from current state (starting a party from
track 3 means tracks 1, 2 and 4 can now be queued too) - a plain function
call from the click handler, not a subscription to musicBar's state, which
was deliberately not built: it would need the same mutable-box
synchronous-cleanup pattern Player.ts uses (Album's render is async,
and a subscription would need unsubscribing via a route cleanup to avoid
leaking a closure over detached DOM nodes) for a live-update that only
matters if someone else's action changes the bar's state while this exact
page happens to be open - a real but narrow case, and the click-time
re-check already makes the only thing that has to be correct safe
either way.
Also starting a party from Album no longer navigates to /party/:id at
all - it calls joinMusicParty directly and stays put, so queuing up
several tracks from the same album is just clicking their buttons too,
with no route round-trip in between.
Verified the same way as the rest of this app's client-side testing: the
real compiled bundle, fetch/window.io/window.Hls mocked, driven
through actual clicks and hash navigation. Confirmed: starting a party
from an album track leaves the sidebar and page in place and correctly
relabels the other tracks; adding a track posts to the already-active
party's id, not a new one; navigating into a video party hides the bar,
disconnects its socket, and posts a real leave for the music party
before the video player ever renders; a party's host still sees the
settings gear open/close correctly inside that same run (see the .hide
fix above).
Device detection: telling a second tab apart from a second device
Requested directly, alongside the crash below (same underlying gap - see
its own comment on the connection between the two): "same account, same
device already in the party" should never offer "Rejoindre" friction at
all, and "same account, a genuinely different device" should ask before
disconnecting it, not silently steal the slot. party.members is
Map<userId, PartyMember> - one entry per Emby account, no concept of
which physical device holds it, so before this there was no way to even
ask the question "is this the same device or not."
- Identity:
client/src/deviceId.ts- a UUID (crypto.randomUUID()) generated once and kept inlocalStorage, notsessionStorage. That choice is what makes "device" mean "browser," not "tab": two tabs of the same browser share onelocalStorage, so they share one deviceId and never conflict with each other, while a different browser or a different computer always gets a fresh one - a real conflict. A save/read failure (private browsing, storage disabled) falls back to a per-page-load id, kept only in memory - correct behavior degrades to "every reload looks like a new device," not a crash. - Protocol:
"join"now carries{partyId, deviceId, force?}instead of just{partyId}(ClientToServerEvents/api/party.ts), andPartyMembergaineddeviceId: string | null(null until a real socket "join" ever succeeds - a member the REST join/create routes just created has no live connection yet, so nothing to compare against). The"join"handler inSocketGatewaynow reasons about three cases, not one:- No existing connection, or the same
deviceIdreconnecting (a reload, a second tab, a dropped connection retrying) → attach silently, and if an old connection for that same device is still technically open, boot it first - it's the same physical device asking again, nothing to confirm. - A different
deviceId,forcenot set → emit"deviceConflict"to the new socket only (never broadcast) and stop there. The socket stays connected but inert - not joined to the party room, the member'sconnectionuntouched - until the client either re-sends"join"withforce:trueor gives up. - A different
deviceIdwithforce:true(the client re-sending after the user confirms) → boot the old connection (errorMessage+disconnect(true)) and take the slot, exactly like the reconnect case above.
- No existing connection, or the same
- Client UX: the answer to "what should happen" was asked directly -
a plain
confirm()in bothPlayer.tsandMusicBar.ts'sonDeviceConflictcallback ("...continuer ici va déconnecter l'autre appareil"), calling the newPartyConnection.confirmDeviceTakeover()(re-emits"join"withforce:true) on yes, or backing out (navigate ('/home')for video,leaveMusicParty()for music - the bar has no page to just walk away from) on no. Kept as a native blocking dialog deliberately: this only ever fires once, right after opening a party, before there's any local playback state a modal could awkwardly interrupt. - A second, independent bug closed by the same pass:
PartyService .join()used to unconditionally build a fresh member on every call, even for a userId already present - so the REST "Rejoindre" route (which calls this every time regardless of whether the caller is already a member) silently resetconnection/isPaused/deviceIdon a re-click, a page reload, or a retried request.join()is now idempotent: an existing member is returned as-is. This isn't just tidiness - it's the other half of the crash below's root cause (two connections silently sharing one map slot), fixed independently of the guard that stops the crash itself. - Generalizing the crash's own fix:
"leave"/"disconnect"used to act onparty(the connection's own closure variable) alone. With a device takeover now a real possibility, the superseded connection's own eventual"disconnect"(Socket.IO fires this automatically right after the server calls.disconnect(true)on it) would otherwise still be read as a real leave - ending the new connection's membership, wrong host handoff, a party destroyed under a still-present member. Both handlers now also checkparty.members.get(userId)?.connection === socketbefore acting - only the connection actually holding the slot can end it. - What this deliberately does not do: change what the home page's
live party list shows before you click "Rejoindre" - it still can't
tell "you're already in this one, same device" from "you're already in
this one, a different device" from "you're not in it at all," because
LobbyPartyDto's one shared snapshot (SocketGateway.lobbySnapshot, broadcast identically to the whole"lobby"room) carries no per-viewer membership at all - giving it that would mean computing and sending a personalized snapshot per subscriber instead of one shared broadcast, a real protocol change of its own, not asked for here. In practice this doesn't cost the feature much: for video, the only way to already be "in" a party while looking at the home page at all is a second tab/device (the player is a full-screen takeover on the one you're using - see "Music bar polish" above for why music's simpler, same-tab case could just be hidden client-side and video's can't), so the conflict check above already is the meaningful moment - clicking "Rejoindre" reaches it immediately, same-device reconnects silently, a different device asks first. The outcome asked for (no friction for your own device, a confirmation before disconnecting another) is there; it just resolves one click later than a home-page button label would, rather than needing a second, heavier protocol change to preempt it.
Verified with new SocketGateway/PartyService tests exercising the
real conflict/takeover/reconnect paths (a different device gets
deviceConflict and the existing connection is left untouched; force: true boots the old connection and the new one takes the slot, and its
superseded predecessor's own later "disconnect" no longer destroys the
party; the same deviceId reconnecting never conflicts with itself), plus
a dedicated PartyService.join() idempotency test, and confirmed
deviceId.ts's localStorage persistence survives a real page reload
against the compiled bundle in a real browser.
Self-hosted ffmpeg: burning in PGS (image) subtitles ourselves
Asked for directly, right after the client-side <track> redesign
("Player track selection" above) closed every text subtitle case:
PGS/VobSub/DVB subtitles (Blu-ray/DVD rips) are bitmaps, not text -
there's nothing for Emby's Stream.vtt conversion to extract, so a
native <track> fundamentally can't render one. getPlaybackOptions
already flagged these isTextBased: false; until now the picker just
left them disabled.
Why running our own ffmpeg can plausibly succeed where Emby's kept
failing (see "Player track selection" for the whole saga): Emby's own
burn-in goes through libass (the subtitles filter), which is text-only
and needs fontconfig/real fonts - the exact thing that was missing, and
even once fixed, apparently still not enough (a different,
never-fully-diagnosed "Error starting ffmpeg" on an otherwise-unremarkable
file). A PGS stream decodes like a second video track; ffmpeg's
overlay filter ([0:v][0:s:N]overlay) composites it directly - no
text rendering, no libass, no fontconfig anywhere in this path. The
Dockerfile installs plain ffmpeg (apk add ffmpeg) - no font packages
at all, unlike what Emby's own text burn-in required.
One shared stream per party, not per viewer -
server/src/services/TranscodeService.ts keys its session map by
partyId alone. This mirrors "host drives, everyone follows" (see
"Realtime transport" above) rather than inventing a second model:
picking a burn-in subtitle is a host-only, party-wide choice
(SocketGateway's setBurnInSubtitle handler, broadcasting
burnInChanged - byte-for-byte the same request/broadcast/react shape
next/itemChanged already use) - at the time this was written, a
text subtitle pick was still each viewer's own local choice; it later
joined this same host-only/party-wide model too, once people actually
used this together and noticed the mismatch - see "Text subtitles join
burn-in as a party-wide, host-only pick" further below. Party gained
burnInSubtitleIndex/
burnInAudioIndex (and PartyPublicState exposes them too) so a
follower joining mid-party, or a page reload, picks up the party's
current choice from a plain getParty() - same reason itemId/
queueIndex already work that way.
Source: ffmpeg is pointed straight at Emby's own direct (static,
untranscoded) file endpoint - /Videos/{itemId}/stream.{container} ?Static=true&MediaSourceId={id} - authenticated the same way
EmbyClient.fetch already does (X-Emby-Token via -headers, never
api_key in the URL). This is the one piece flagged TODO(verify):
there's no Emby server reachable from the environment this was built in,
so the exact shape is unconfirmed against this app's own real server -
see TranscodeService.ts's buildSourceUrl. If it's wrong, it's
isolated to that one function.
Stream indices: Emby's MediaStreams[].Index is one flat, absolute
number across video+audio+subtitle together (the "stream 6/7/8" this
app's own picker already surfaces) - ffmpeg has no notion of that at
all, only type-relative specifiers (0:s:N/0:a:N, counting only among
streams of that type). absoluteToRelativeIndex does the conversion -
pure array math, fully unit-tested with no ffmpeg or Emby involved (see
transcodeService.test.ts).
Output: real, progressively-written HLS (-f hls, a growing
master.m3u8 + segments) to a temp directory, served by two new routes
(GET /api/catalog/burn/:partyId/master.m3u8 and .../:segment) that
poll briefly for the next file to exist rather than failing immediately -
ffmpeg is still writing while a viewer's hls.js is asking for it.
Deliberately not "transcode the whole file first, then serve it": for a
two-hour movie that would mean a wait proportional to its length before
anyone could press play at all, which defeats the point of a watch
party. This also means the client needs no new player logic at all -
same hls.js, same manifest/segment polling, just pointed at a different
server path (api/embyApi.ts's burnInStreamUrl) - see
views/player/Player.ts's loadVideo.
Lifecycle: a session tracks {process, outputDir, lastAccessUtc};
every request refreshes it. PartyService.leave's destroy branch and
.advanceQueue (a new item's stream indices mean nothing on the old
one) both end a party's session immediately; removeIdleParties's own
idle sweep and a shorter, dedicated TranscodeService.reapIdleSessions
interval in index.ts (30min idle, checked every 5min - shorter than
parties themselves, since this holds a real OS process + disk, not just
an in-memory object) are the backstop for anything that doesn't end
cleanly. ffmpeg's stderr is captured into a rolling buffer and logged
through a new Logger.transcode() (same reasoning as Logger.emby -
see "Server-side logging" above) so a failure is visible in docker logs immediately instead of a stuck spinner in the browser.
Player UI: the subtitle <select>'s image-track options go from
unconditionally disabled to host-only selectable (Player.ts's
renderSubtitleOptions, gated on the same isHost that already gates
nextBtn/the settings menu), labeled to say picking one restarts the
stream for everyone. While a burn-in stream is active, the audio/quality
selects are disabled (the burn-in is one fixed encode) and a follower's
subtitle select is disabled too, since their own text-subtitle/"Aucun"
choice would no longer do anything - only the host, who can also change
or clear it, keeps it interactive. Every member (host included) applies
a burn-in change purely by reacting to the resulting burnInChanged
broadcast (onBurnInChanged, reloading loadVideo) - never
optimistically, same discipline applyRemoteState/onItemChanged
already use for every other party-wide state change.
(A follower's subtitle select being disabled only while burn-in is active, as just described, didn't last - see "Text subtitles join burn-in as a party-wide, host-only pick" below, where the whole select became host-gated unconditionally instead.)
Verified locally to the extent this dev environment allows (no Emby
server, no ffmpeg binary reachable here either): full typecheck/test/
build on both projects; absoluteToRelativeIndex, the exact ffmpeg
argument list, per-party session start/reuse/replace, and the idle
reaper are all unit-tested with child_process.spawn mocked (see
TranscodeService.setSpawnForTesting - a deliberate, explicit test seam
rather than relying on module-mock timing); SocketGateway tests cover
setBurnInSubtitle's host-only gating and its burnInChanged broadcast.
The one thing that can't be proven here - an actual PGS file burning in
and playing against the real server - waits for a real try, the same
way the whole subtitle saga above was ultimately diagnosed from the
user's own pasted container logs, not local testing.
A real server crash: one userId, two connections, one map slot
Reported directly from the running container's own crash output, not
found by testing: TypeError: Cannot read properties of undefined (reading 'isPaused') inside SocketGateway's "playstate" handler,
which took the entire Node process down - every active party, not just
one connection. At the time this was found, party.members was a
Map<userId, PartyMember> with no per-device identity at all - the same
gap the "Device detection" section right above eventually closed, once
that was actually asked for; this section describes the state of things
before that, and the immediate fix that had to stand on its own regardless
of whether a fuller fix ever followed. Two connections for the same
userId - two tabs, a second device, a POST /api/parties/:id/join
retried - end up sharing that one map slot: PartyService.join/.create
both just members.set(userId, newMember), unconditionally overwriting
whichever member was there.
The actual crash needed one more step, and it's a real gap in
PartyService.leave, not just the sharing itself: when the connection
currently occupying the slot is the party's last member and leaves,
leave() deletes it, finds no successor to hand the host role to, and -
by design, per its own comment ("this stale value is never read again",
since the party is destroyed in the same call) - never updates
party.hostUserId. That assumption held as long as nothing else could
still reach the now-destroyed Party object. Something could: the
other connection for that same userId, still fully connected, whose own
closure captured the exact same Party object reference back when it
first joined. Its regular 3-second heartbeat ("playstate") fires next,
checks userId !== party.hostUserId - both sides of that comparison are
the same stale userId, so it passes - and then reads
party.members.get(userId)! on a map that no longer has that entry at
all. undefined!.isPaused is where it actually threw.
Fixed at two levels, not just the one line:
- The immediate cause:
party.members.get(userId)!in the"playstate"handler is now a realif (!member) returnguard, matching the pattern every other handler already used for the same lookup (voteSkip/musicPlaystate/requestPause) - this one was just missed. - The systemic gap: nothing in
SocketGatewayhad ever needed the equivalent ofindex.ts'sasyncRoute()before, because nothing else here had thrown - but Socket.IO has no built-in equivalent of Express's error-handling middleware at all. A synchronous throw from any listener propagates straight out ofSocket.emit()and crashes the whole process, unconditionally - not just this bug, any future one, in any handler. Everysocket.on(...)registration inattach()now goes through a newsafe()wrapper (log-and-swallow on an unexpected exception) - the same "don't let one bad case take down every active party" instinctasyncRoute()already applied to REST routes, just never extended to the realtime side until this forced the question.
Verified with a real regression test reproducing the exact sequence
(two joins for one userId, the occupying one leaving as the party's last
member, the other's next heartbeat) - confirmed it doesn't throw, and
specifically confirmed it's the guard doing that (a clean early return)
rather than the generic safe() net merely catching and hiding an actual
throw, by temporarily reverting just the guard and watching that same
test correctly fail before putting it back.
Deliberately not "fixed" by changing how join/leave share that map
slot in this same pass - that real design work (identifying a device,
deciding what "same account, different device" should actually do
instead of silently overwriting) landed separately, right above, once
it had actually been asked for. This pass alone was enough to stop it
from ever being able to crash the server again in the meantime,
regardless of how - or whether - that got resolved.
Playlist browsing + launch
Asked for directly, queued right after the ffmpeg burn-in work above: see what's inside an Emby playlist, and launch it as a party pre-queued with the whole thing - the same "start from one click, the rest queues itself" shape starting from an episode already gets for its series.
The sidebar needed nothing new: Emby's /Views already returns a
CollectionType: 'playlists' entry once the user has at least one -
CatalogService.VISIBLE_COLLECTION_TYPES just had to stop dropping it,
and it shows up in Shell.ts's sidebar through the exact same code path
every other library already does (see "UI: Emby-like shell" above).
Library.ts's existing generic branch (getLibraryItems - direct
children of a ParentId) already returns a playlist library's own
playlists correctly; only music's artist grid needed a dedicated
endpoint, because a flat listing there would have returned raw tracks
instead of the artist grouping its own page needs. Playlists don't have
that problem - a playlist library's children already are the playlists
themselves.
Tile drill-down, not a direct "start party" button: like
Series/MusicArtist/MusicAlbum before it, a playlist tile links into its
own page (tiles.ts's DRILL_DOWN table, Playlist: { route: 'playlist', ... }) rather than getting canWatchParty's button
directly - a playlist itself was never something Emby could stream on
its own.
Scope, deliberately narrower than it could be: the new
views/playlist/Playlist.ts page is read-only past the one "Lancer
cette playlist" action - no per-item start/add the way views/album's
track rows or views/series's episode rows get. That's not laziness:
an Episode carries SeriesId, which is what lets POST /api/parties
reconstruct "queue the rest of the series from here" from just the
clicked item's id - a playlist item carries no equivalent
"which playlist(s) contain me" back-reference, so "start from item N,
queue the rest of this playlist" isn't cheaply reconstructable the
same way. Building that properly would mean threading a playlist id
through the click, a real protocol change for a page whose actual ask
was launching the playlist as a whole, not per-item granularity - left
for if it's ever actually asked for.
Launching one: POST /api/parties (index.ts) already special-cases
item.Type === 'Episode' to build a series queue; a playlist launch is
a sibling early branch (item.Type === 'Playlist') calling a new,
separate createPartyFromPlaylist helper rather than merged into the
existing single-item path - that path is already working/relied-upon
and didn't need touching. Emby allows mixing movies/episodes and songs
in one playlist; this app's party model can't (a party is either
'video' or 'music'). CatalogService.splitPlaylistByMediaType - a
small, pure, fully unit-tested function - decides which from the
playlist's first item and drops anything that doesn't match, logged
server-side so a mixed playlist isn't silently confusing from docker logs. A video result builds a QueueItem[] exactly like the
episode-queue path already does; a music result seeds
MusicQueueService.init with the first track and addTracks the rest -
the same mechanism POST /api/parties/:id/tracks already uses, just
called several times in a row here instead of once per click.
Launching from the client: Playlist.ts's one button calls the
existing createParty(playlistId) - no new client API for starting
one, since the server already decides everything from the id alone, the
same as clicking any other item. It then branches on the created
party's mediaType (joinMusicParty vs. navigate('/party/:id')) -
the same check Player.ts's own dispatcher already makes, just once,
at creation time instead of at every join.
Verified: the new CatalogService methods and splitPlaylistByMediaType
(homogeneous video, homogeneous music, mixed - first item's type wins)
are unit-tested with a mocked EmbyClient, matching every other
catalogService.test.ts case. Full typecheck/test/build on both
projects. The new page was driven through the same mocked-fetch +
real-bundle harness used throughout this session in a real browser -
confirmed the header/meta/item list render correctly from mocked catalog
data, and that clicking "Lancer cette playlist" reaches
POST /api/parties with the right id and correctly routes into the
video player for a video result (the same standing caveat as everywhere
else applies past that point - no real Emby server reachable here to
confirm actual playback).
Sync watch-party progress back to Emby (PlaystateService)
Everything before this section reads from and proxies Emby; nothing
ever wrote anything back. Asked for directly: while someone's in a
party, push their progress to Emby's own PlaystateService API (its own
controller name in both Emby's and Jellyfin's server source) so their
own Emby library - resume position, last-played state - stays in sync
with what happened in the party, under their own account, not just
this app's.
Scope, deliberately narrower than the full lifecycle: only the
periodic POST /Users/{UserId}/PlayingItems/{Id}/Progress call is made.
No explicit start (POST .../PlayingItems/{Id}) or stop
(DELETE .../PlayingItems/{Id}) calls - Emby's own "now playing"
session dashboard won't show this app's streams, and the precise "mark
fully watched" heuristic (normally tied to a clean Stop near 100%) won't
fire deterministically. Steady Progress calls alone still update resume
position and last-played state either way, which is the actual core of
what was asked; wiring clean start/stop into every transition (item
change, leave, party destroy, a device takeover) is real complexity for
a secondary payoff, left as a documented follow-up rather than built
speculatively.
The one real gap this ran into: reporting a follower's own
progress needs their own position, but video parties only ever track
the host's (PartyMember.isPaused/positionTicks are written solely
by the host's own "playstate" handler - see "Realtime transport"
above). Building genuine per-follower position tracking would be a real
protocol addition of its own. Instead, every member's video report uses
the host's tracked position - already what their own player is supposed
to closely track via its drift correction (see "Player: no page
scroll"/applyRemoteState), so this is a reasonable approximation, not
a hack that quietly reports the wrong thing. Music has no such gap at
all: party.music.current plus its elapsed time is already one shared,
server-authoritative position every member already sees the same way -
nothing to approximate there.
Getting each member's own token to where it's needed turned out to
already be most of the way there. SocketGateway.attach's getSession
callback is wired (index.ts) to AuthService.getSession, which
already returns the full EmbySession (including embyToken) - the
callback's own parameter type just narrowed it down to {userId, userName} before anything downstream could read the rest. Widening
that type was the entire fix. PartyMember gained embyToken: string | null (same null-until-known shape as this session's own deviceId
addition) - set in the socket "join" handler alongside deviceId, and
via a new, small PartyService.setMemberEmbyToken(party, userId, embyToken) called right after each of the 4 REST create/join call
sites in index.ts, so a member has a valid token from the moment
they're a member, not only once their socket first connects. Never sent
to the client - PartyService.toPublicState's member mapping never
included deviceId either, and doesn't grow this one.
One new pure, tested function carries the real logic:
PartyService.getCurrentPlaybackState(party) - video vs. music, per the
Approximation note above - null when there's genuinely nothing to
report (an empty music queue). Same "the interesting bit gets its own
tested function, the timer loop around it stays thin and untested" shape
this session already used twice (absoluteToRelativeIndex,
splitPlaylistByMediaType).
Calling Emby needed EmbyClient.fetch to grow beyond
GET-with-headers - it now optionally takes method/body
(JSON-encoded here, once, so every caller passes a plain object), every
existing call site unaffected since both are optional. New
server/src/services/PlaystateService.ts wraps the one call this needs.
Failures are caught and logged (new Logger.playstate) rather than
thrown - this runs on a plain setInterval (~20s, alongside
removeIdleParties/TranscodeService.reapIdleSessions's own timers)
with no request to fail and no business ever disrupting real playback
over a background sync hiccup.
Round 1, TODO(verify) resolved - and the original guess was wrong.
Checked directly against Emby's own real OpenAPI spec
(swagger.emby.media, pointed at a real server) after a "watch status"
bug report suggested this wasn't actually working: POST /Users /{UserId}/PlayingItems/{Id}/Progress does exist at that exact path,
but the spec declares PositionTicks/IsPaused as query string
parameters, not body fields - and a third parameter, MediaSourceId,
is required and wasn't being sent at all. Fixed: sent as query
parameters instead, MediaSourceId resolved from the same
PlaybackInfo negotiation CatalogService.negotiateStreamTarget
already does for the party's shared stream. While checking this, the
app's other two Emby-API assumptions (the ffmpeg direct-stream URL,
the /Playlists/{Id}/Items endpoint) were re-checked against the same
real spec too - both already correct.
Round 2, caught immediately from new logging added specifically to
catch exactly this (every outgoing request + its outcome, under
[watchparty:playstate] - see Logger.playstate's own comment for why
this needed to be enough on its own to answer "is this actually
reaching Emby, and with what"): a real request logged after round 1
shipped still came back 400, now Value cannot be null. (Parameter 'key') - a bare .NET Dictionary null-key exception, and not the
earlier problem (MediaSourceId was present and well-formed).
PlaySessionId was the standout candidate - how Emby/Jellyfin clients
normally correlate a report with the specific playback session it
belongs to - and sending it (resolved the same way MediaSourceId
was) fixed this particular error.
Round 3: a further report questioned the whole approach, not just
its request shape - "still don't see this in my Emby profile /
Continue Watching" - and that turned out to be the right instinct.
This app deliberately never calls the matching POST .../PlayingItems /{Id} ("Start") or DELETE .../PlayingItems/{Id} ("Stop") - a
documented v1 scope cut (see the original framing of this section,
still true) - and a bare Progress report with no corresponding
"Start" may simply never reach the resume-position/watched-history
data real clients read, regardless of how correctly-shaped the request
is by that point. Confirmed directly against the real server that this
data lives somewhere else entirely, trivially reachable without any
session lifecycle at all: every item Emby returns already carries the
requesting user's own UserData embedded
(PlaybackPositionTicks/Played/PlayCount/IsFavorite/...) - the
exact thing "Continue Watching" and the watched checkmark actually
read - and a plain POST /Users/{UserId}/Items/{ItemId}/UserData
updates it directly.
Switched to that entirely rather than layering a third fix onto
PlayingItems - PlaystateService.updatePosition (periodic, ~20s,
replacing reportProgress) and a new PlaystateService.markPlayed
(see below) both go through one private updateUserData helper.
Simpler than the endpoint it replaces (one JSON body, no query-string
parameter shape to get wrong) and more direct (writes the exact data
model the UI reads, rather than a live-session side-channel whose
effect on that data model was the very thing in doubt). The
MediaSourceId/PlaySessionId plumbing rounds 1-2 added
(CatalogService.negotiateStreamTarget returning them,
PartyStreamService caching them) was removed along with it -
negotiateStreamTarget is back to returning a bare target path, since
nothing needs those two values for anything else.
Fetch-merge-write, deliberately, never a bare partial POST. Emby's
OpenAPI spec documents the UserData endpoint's body as the complete
UserItemDataDto, with nothing suggesting a partial object merges
rather than replaces - and this is the one write in the whole app where
guessing wrong risks actually erasing a user's own real data (a
favorite, a rating), not just silently doing nothing the way the
Progress-endpoint bugs did. So every call to updateUserData first
GETs the item (confirmed directly against the real server that Emby
always returns it with the requesting user's current UserData
embedded), applies just the one or two fields being changed to a copy
of exactly that, and POSTs the whole result back - correct regardless
of which way the real merge-vs-replace semantics actually go, at the
cost of one extra read before every write.
Marking items Played, addressing the other half of the same
report ("les éléments visionnés marqué comme lu") - not attempted in
any earlier round. PlaystateService.markPlayed sets Played: true
and resets PlaybackPositionTicks to 0, called once per currently-
connected member (with a token) at the exact moment the party moves
past an item for good: PartyService.advanceQueue (next episode, for
the outgoing item, captured before the party's own state moves on)
and MusicQueueService.playNext (a track naturally finishing, or a
majority vote-skip - both go through this one method, so both get the
same treatment; music.current captured as outgoing before it's
reassigned). Fire-and-forget, same as the periodic position sync - a
member's bad token or a transient Emby hiccup must never block the
party from actually advancing. This is a deliberate heuristic ("the
party moved on from it" = "done with it"), not a real "did they watch
the whole thing" check - this app has no reliable way to know that
beyond what it already tracks, and most real streaming clients treat a
skip as a play too. Precision beyond that (e.g. only marking Played
past some percentage watched) would need every queued item's own
duration tracked everywhere it's queued, which nothing currently does -
left as a documented gap, not built speculatively.
Verified: full typecheck/test/build on both projects. Unit tests cover
getCurrentPlaybackState, setMemberEmbyToken,
PlaystateService.updatePosition/markPlayed's fetch-merge-write
behavior in detail - a favorite/rating/play-count this app never asked
to change survives a position update byte for byte, a Played-only
update via markPlayed doesn't disturb IsFavorite, a failed read
skips the write entirely rather than posting incomplete data, an item
with no UserData at all yet starts cleanly from an empty object - its
own request/outcome logging (success, a non-ok status on either call,
and a thrown error each produce a distinct, greppable line, and the
token itself never appears in one), and that
PartyService.advanceQueue/MusicQueueService.playNext call
markPlayed for the outgoing item only, only for members who are
both connected and have a known token, mirroring the exact same guard
index.ts's periodic sweep already uses. EmbyClient.fetch's optional
body-encoding capability (added for this feature's very first attempt)
stays generic plumbing - still exercised by this feature's own
UserData POST, so unlike after round 2 it's not just kept around for
a hypothetical future caller. No client changes at all - this is a pure
server-side background/event-driven process with nothing for the
browser to render or react to.
One shared Emby stream per party (not one per viewer)
Asked for directly: exactly one stream should ever be open between this
app and Emby per party, no matter how many members it has - before
this, every viewer's own <video>/<audio> independently negotiated
its own PlaybackInfo and got its own PlaySessionId, so a party of 5
meant Emby transcoding the same title 5 separate times. This is a
bigger, higher-regression-risk change than most of this session's other
work: it replaces the core playback path every existing party already
relies on (CatalogService.proxyStream and its route are gone
entirely) rather than adding something new alongside it - there was no
other consumer of the per-viewer path to preserve for, and the ask was
explicit that there should only ever be one mechanism, not two.
A new cache, not new state on CatalogService (that class is
deliberately stateless - its own comment says so): server/src/services/ PartyStreamService.ts owns Map<partyId, {itemId, audioStreamIndex, maxStreamingBitrate, targetPath, lastAccessUtc}> - the same shape as
TranscodeService's own session map, for the same reason (one entry
per party, replaced when the desired parameters change, reaped when
idle). getOrNegotiate(party, session) reads what the party currently
wants to stream (video: party.itemId; music: party.music.current's
track - there's no host to read a position from the way video does,
but there's also only ever one shared position/track for the whole
party already, see "Music" above) and either reuses the cached target
or renegotiates.
The negotiation itself was extracted, not duplicated:
CatalogService.proxyStream's old body (PlaybackInfo, then Emby's own
TranscodingUrl or a hand-built fallback, with the audio/quality
override applied twice - once to the negotiation, once to whatever URL
results, so a pick can't silently no-op regardless of which path Emby's
response takes) is now negotiateStreamTarget(session, itemId, isAudio, audioStreamIndex, maxStreamingBitrate): Promise<string> - identical
logic, just returning the target instead of proxying it directly, so
PartyStreamService can cache what comes back.
Recovering from a dead upstream session needed no new plumbing:
Emby's own transcode isn't guaranteed to live forever, and a cached
target could start failing if Emby ends it on its own side.
EmbyClient.proxyResponse already calls res.status(...) before
writing the body, so res.statusCode is readable right after it
resolves - PartyStreamService checks that and drops the cache entry
on any failure, so the next request (a manual retry via the player's
own "Réessayer" button, or simply the next segment fetch) renegotiates
fresh instead of repeating the same dead target forever. This is the
same recovery a single failed per-viewer session already relied on
before - just applied once per party instead of once per viewer.
Routes, keyed by partyId (membership-checked, same pattern as the
burn-in routes above): GET /api/catalog/party-stream/:partyId/ master.m3u8 and .../raw?u=... replace /api/catalog/stream/:itemId/ ... entirely. EmbyClient.rewritePlaylist/proxyResponse's trailing
param (itemId before this) is genericized to routeKey - it's only
ever read back out to build that second URL, so it's a real itemId for
proxyImage/proxySubtitle (which never actually reach the
playlist-rewrite branch anyway - wrong content-type/extension, so this
was always inert for them) and party.id for the new stream routes.
Forced consequence, not a free design choice: with only one stream,
there's only room for one set of encoding parameters behind it - a
video party's audio track and quality became a host-only, party-wide
choice (Party.streamAudioIndex/streamQuality, setStreamOptions/
streamOptionsChanged), byte-for-byte the same shape
setBurnInSubtitle/burnInChanged already established for the
image-subtitle case. Player.ts's audio/quality <select>s are
disabled for a follower (applyStreamOptionsGating, mirroring
renderSubtitleOptions's own gating) and restore the party's current
choice on load or broadcast instead of defaulting to the first option;
a host's change calls setStreamOptions instead of reloading locally,
and - like every other party-wide change this app makes - every member
(host included) only ever reacts to the resulting broadcast, never
applies a pick optimistically. streamAudioIndex resets on an item
change (an audio track index is exactly as item-specific as a
burn-in subtitle index is); streamQuality deliberately doesn't - a
bandwidth preference isn't tied to any one item, and wasn't reset
across an item change even before streams were shared either
(qualitySelect's options were never rebuilt per-item).
Music needed no picker at all - MusicBar.ts never had an
audio-track/quality <select> in the first place, so sharing its
stream (partyStreamUrl(activePartyId), same function the video player
uses) was the caching mechanism alone, no permission model to design.
A track change (MusicQueueService.playNext) ends the party's cached
session the same way PartyService.advanceQueue already does for
video - whatever was negotiated was for the outgoing track.
Verified: full typecheck/test/build on both projects. Unit tests cover
negotiateStreamTarget (moved from proxyStream's old coverage) and
PartyStreamService's cache hit/miss/param-change/item-change behavior,
including the failure-invalidation path, plus setStreamOptions's
host-only gating and broadcast (mirroring the existing setBurnInSubtitle
tests). A real-browser check (mocked-fetch + real bundle, this
session's standing harness) confirmed a follower's audio/quality
selects render disabled and a host's render enabled, from the same
party data with only youAreHost flipped.
TODO(verify): this is a different kind of unverified than this
session's other Emby-API guesses (the ffmpeg direct-stream URL, the
Playlists endpoint, the playstate-sync endpoint) - the actual endpoint
shape here (PlaybackInfo, TranscodingUrl) was already live,
confirmed code, just newly shared instead of per-viewer. What's
genuinely unverified is behavior under real multi-viewer load: whether
a real dead Emby session actually surfaces as an HTTP failure the way
invalidateOnFailure assumes, and how a real transcode's own seek
handling behaves once several viewers depend on the exact same one
instead of each having their own. Worth trying with two real accounts,
same standing item already on this project's own roadmap.
Text subtitles join burn-in as a party-wide, host-only pick
Reported directly, after the shared-stream work above shipped: watch
party members still didn't have "synced viewing settings" for
subtitles. The report was accurate but the shared-stream work only
half-closed the gap - it made audio/quality/burn-in host-only and
party-wide, but a plain text subtitle pick (the common case; burn-in
only ever applies to bitmap PGS/VobSub/DVB tracks) was still each
viewer's own local <track> choice, exactly as it always had been
since "Player track selection" above. Once people actually watched
together, "everyone but me changed the subtitle" read as broken sync,
not as a deliberate per-viewer feature.
A parallel mechanism, not a merge into burnInSubtitleIndex - the
two picks are similar in shape but stay separate fields:
Party.textSubtitleIndex: string | null, a new setTextSubtitle/
textSubtitleChanged socket pair, byte-for-byte the same host-only,
video-only gating and request/broadcast/react shape
setBurnInSubtitle/burnInChanged already used. Keeping them separate
(instead of reusing burnInSubtitleIndex for both) avoided touching
the already-proven burn-in path at all - SocketGateway's existing
setBurnInSubtitle handler gained exactly one new line to clear
textSubtitleIndex when it sets a real value, and setTextSubtitle is
new code entirely. Only one subtitle can be showing at once, so the
two are mutually exclusive - enforced on the server, in both
directions: a real (non-null) pick on either handler clears the other's
burnInSubtitleIndex/textSubtitleIndex. Clearing one to null/"Aucun"
is deliberately not treated as a pick for this purpose - it doesn't
touch the other field, so picking a burn-in and then explicitly
clearing it back to "Aucun" doesn't also silently wipe out an unrelated
text choice that was never actually in the way.
Mechanically, the two still work nothing alike underneath - the
whole reason this wasn't just a rename of burnInSubtitleIndex: a
burn-in choice changes the actual encoded stream (ffmpeg's overlay
filter), so every member reloads through loadVideo when it changes.
A text choice only ever selects which native <track> each member
already renders locally (applySubtitleTrack) - no transcode, no
reload, no re-buffering, the entire point of rendering text subtitles
client-side in the first place. Player.ts's onTextSubtitleChanged
reflects this: it's normally just a <track> swap, except when the
text pick is what clears an active burn-in (the select allows picking
a text option, or "Aucun", at any time now - see below - even while
burn-in is active) - that one transition really does change the
underlying stream back to PartyStreamService's normal one, so it
still needs the same reload onBurnInChanged already uses.
Player UI: renderSubtitleOptions's gating stopped being
conditional on burn-in - subtitleSelect.disabled is now simply
!isHost, unconditionally, matching applyStreamOptionsGating's own
reasoning for audio/quality. The select's value on load or broadcast
restores from burnInSubtitleIndex || textSubtitleIndex || <previous value> - whichever kind of pick, if any, the party currently has.
Changing the select now always goes to the server - an image option
calls setBurnInSubtitle; everything else (a text option, or "Aucun")
calls setTextSubtitle - never applies anything locally first, same
discipline every other party-wide control in this app already follows.
A second, unrelated bug in the same report: "the language selector
shows an empty choice at the start." Root cause was in
audioSelect.value = streamAudioIndex || ''; (both in
loadTrackOptions and onStreamOptionsChanged) - streamAudioIndex
is null by default (Emby's own default, nothing picked yet), and a
real audio-track <option> never has value="" (only the
no-tracks-at-all fallback branch does), so assigning '' matched
nothing and left the select's selectedIndex at -1 - a genuinely
empty-looking choice, not merely "showing the default option". Fixed
at both call sites by only assigning .value when streamAudioIndex
is truthy, otherwise leaving the select to fall back to its first real
option the way an untouched <select> already does. Unrelated to the
subtitle-sync fix above beyond living in the same file and the same
bug report - qualitySelect never had this problem, since its preset
list (QUALITY_PRESETS) already has an explicit value="" "Auto"
option to match against.
Verified: full typecheck/test/build on both projects. SocketGateway
tests cover setTextSubtitle's host-only gating, its broadcast, its
no-op on a music party, and the mutual-exclusivity behavior in both
directions (mirroring the existing setBurnInSubtitle tests);
PartyService tests cover textSubtitleIndex through create/
advanceQueue/toPublicState. A real-browser check (mocked-fetch +
real bundle, this session's standing harness) confirmed: a follower's
subtitle select renders disabled regardless of burn-in state; a host's
select restores the party's current text or burn-in pick correctly on
load; and the audio select now lands on a real first option instead of
an empty one when streamAudioIndex is null.
Catalog browsing was slow: images were never cacheable
Reported directly ("l'api du contenu des catalogues est hyper lente").
CatalogService's own listing methods (getLibraryItems, getArtists,
etc.) were already fine - each is exactly one Emby call, no per-item
follow-up requests, and timed directly against the real server a
300-item library listing (getLibraryItems's own Limit: 300) came
back in well under half a second. The real cost was downstream of that:
every one of those up to 300 tiles needs its own thumbnail, each
proxied through EmbyClient.proxyImage → proxyResponse, and
proxyResponse was silently dropping every caching signal Emby sent -
confirmed directly against the real server that an image response
already carries Cache-Control: public and a real ETag, but this
method only ever forwarded content-type/content-range/
accept-ranges. With no validator and no cache policy ever reaching
the browser, it had no choice but to re-download every single thumbnail
in full, from scratch, on every visit to every library, home-page
carousel, series/season/album page - the same images, over and over,
compounding across however many tiles a page shows.
Fixed both directions of the same problem. EmbyClient.fetch
already accepted range to forward; it now also accepts
ifNoneMatch/ifModifiedSince, sent as If-None-Match/
If-Modified-Since - and a 304 (the entire point of sending those)
is explicitly excluded from the failure-logging path (!res.ok is true
for a 304, but it's Emby correctly saying "unchanged", not an error
worth [watchparty:emby]). proxyResponse now reads the browser's own
If-None-Match/If-Modified-Since request headers and forwards them
to Emby, and forwards cache-control/etag/last-modified back from
Emby's response, alongside the existing content-range/accept-ranges
pair. Confirmed end to end against the real server, not just reasoned
about: a plain image GET comes back 200 with Cache-Control: public
- a real
ETag; the same request repeated with thatETagasIf-None-Matchcomes back a real304with a zero-byte body. Harmless for everything else this method proxies (video/audio segments, HLS playlists, subtitles) - it only ever forwards whatever Emby itself decided, never invents a policy, so something Emby doesn't mark cacheable (a per-session transcode) simply never gets these headers set at all, same as before.
Verified: full typecheck/test/build on both projects. New
EmbyClient.proxyResponse tests (this method had never been exercised
directly before - every existing test that touches it mocks it away
entirely to isolate whatever calls it) cover forwarding the cache
headers on a real bodied response, forwarding the browser's conditional
headers to Emby and relaying a real 304 with no body, and that a
response with neither header (most proxied paths) doesn't choke or
invent one; EmbyClient.fetch tests cover a 304 not hitting the
failure log and the two new headers being sent only when given.
A spinner on every loading state
Asked for directly - every "Chargement..." spot across the app (route
entry for Album/Artist/Library/Playlist/Series, the sidebar's own
library list, Home's per-library carousels, a season switch reloading
its episode list, the player's video-buffering notice) was plain
.hint text with no visual motion, easy to mistake for a stuck page
rather than one still fetching. New .spinner in styles/base.css - a
plain CSS ring (border + border-top-color, no image/icon-font
asset), display: inline-block with a small negative vertical-align
so it drops into an existing text node instead of needing the element
restructured into a flex row; @media (prefers-reduced-motion: reduce)
turns off the spin for a viewer who's asked for less motion, matching
this app's existing carefulness about motion elsewhere.
New client/src/loading.ts exports one shared LOADING_HINT constant
(<p class="hint"><span class="spinner"></span>Chargement...</p>) so
every dynamic call site renders identical markup instead of each
hand-typing a slightly different one; the handful of static HTML
templates that had their own inert copy of this same placeholder text
(never actually paintable - immediately overwritten synchronously by
the same script that just set it, so a viewer could never see the
old, spinner-less version even for a frame) were left alone rather
than churned for a change nobody could ever see; Library.html's own
#grid placeholder was the one exception - genuinely visible while
its library's items load - and got the same markup pasted directly
(a static template, no JS import of its own).
Not touched: the brief button-label loading states elsewhere ("Ajout...",
"Démarrage..." while a party/queue-add request is in flight) - a
different, smaller UI element than a block-level .hint, and typically
resolved within one REST round trip, low enough value to add a second
spinner variant for.
Verified: full typecheck/build on the client (no server changes at
all - this is pure client markup/CSS). Visually confirmed in a real
browser (the compiled bundle's own app.css) that the spinner renders
correctly inline with text in both a plain .hint and the player's
.loadingNotice context, and is genuinely animating (getComputedStyle
sampled twice, 300ms apart, showed a different transform matrix each
time).
Casting to a Chromecast
Asked for directly, with a detailed spec naming the exact services/files
to touch: let a party member cast the party's video onto a Chromecast
(Google's Default Media Receiver - no custom receiver, so no Google Cast
Developer Console app registration needed at all) with play/pause/seek
shown as a remote on the sender's own screen. The spec's description of
this app's architecture was verified directly against the real code
before building anything: every party already streams through one
shared, party-keyed relay (PartyStreamService/TranscodeService,
proxied via EmbyClient, see "One shared Emby stream per party" above)
as standard HLS, and the server proxies 100% of Emby access - the
browser never holds an Emby token, only the wp_session cookie (see
"Goals"/AuthService). A Chromecast is a new kind of client for that
relay: it can't carry the sender's httpOnly cookie at all, so it needed
its own narrow way in without weakening "the server holds the only real
Emby token" that the rest of the app is built around.
Scoped to video parties for v1 - the spec's every concrete reference
(the player, the party-stream/burn routes, the burn-in fallback) was
video-specific, and MusicBar.ts had no comparable control surface at
the time. Extended to music too shortly after, once asked for directly -
see "Casting a listen party" below for what that actually took (turned
out to be almost entirely client-side: every piece of server support
below - CastTokenService, the 4 streaming routes' sessionForStream
fallback - was already media-type-agnostic, scoped to a partyId, never
a video-specific assumption).
CastTokenService (new, same shape as AuthService: an in-memory
Map, opaque crypto.randomUUID() keys, TTL-checked on read) issues a
short-lived token scoped to one {partyId, userId}, standing in for
that member's own already-authenticated EmbySession wherever a
Chromecast needs to reach the party's stream. Two new routes:
POST /api/parties/:partyId/cast-token (normal cookie auth, mints one)
and DELETE /api/parties/:partyId/cast-token (revokes it - called when
a cast session ends). The 4 routes a Chromecast actually needs to reach
party-stream/:partyId/master.m3u8/raw,burn/:partyId/master.m3u8/:segment- gained one small fallback (sessionForStreaminindex.ts): try the session cookie first, same as ever, then acastTokenquery param. Either way the result is the exact sameEmbySessiontype every other route already uses, so nothing downstream (PartyStreamService/TranscodeService/EmbyClient) had to change at all. The image/subtitle routes were deliberately left cookie-only - outside the spec's own 4-route list, and a Chromecast's Default Receiver has no use for either here.
A token's TTL (12h) is deliberately generous - it's only a backstop.
The real invalidation is explicit and unconditional: PartyService .leave() already runs a few unconditional per-leaving-member cleanups
(MusicQueueService.removeVote, right after the member is removed from
the map) - CastTokenService.revokeForMember joined that list, so a
token dies the instant its owner actually leaves the party, whether or
not the client ever got a chance to call the explicit "stop casting"
route (a crashed tab, a Chromecast that just loses power). Even a token
that somehow outlived both of those is no more powerful than "this
party member's own streaming access" - every one of the 4 routes still
separately checks party.members.has(session.userId), exactly like a
cookie session already does.
The sender side loads the CAF Sender SDK the same way hls.js/
socket.io already are - a pinned CDN <script> (Google only ever
serves this from its own gstatic host, not a versioned CDN like the
other two, and it signals readiness via a global callback rather than
being usable synchronously right after load). New client/src/cast.ts
is the only module that touches the SDK directly, and exposes a small
PlaybackDriver interface (currentTime, paused, play(),
pause()) with two implementations - one over the local <video>, one
over CAF's RemotePlayer/RemotePlayerController (which only ever
pushes state via events, so the wrapper caches it behind plain
getters). This turned out to be the only new abstraction the whole
feature needed, not a new sync mechanism: Player.ts's existing
sendLocalState/applyRemoteState already read and write "the player"
in exactly one place each, so pointing them at whichever driver is
active - instead of video directly - carried over the host-only gate,
the drift-tolerance reconciliation, and the 3s heartbeat unchanged for
either one. Seek was already "the host's next state broadcast" with no
separate seek event, so seeking on the TV is already party-wide for
free. A non-host caster already only ever received applyRemoteState
(the host-only check in sendLocalState already blocks a follower from
pushing) - redirecting that to the cast driver instead of the local
<video> reproduces "still dependent on the host" automatically, with
zero cast-specific code for either of those two behaviors.
Starting a cast: POST cast-token, build an absolute
partyStreamUrl/burnInStreamUrl carrying it as ?castToken=...,
requestSession() → loadMedia(), then tear down the local hls.js
instance (hls.destroy() - the same full teardown-and-rebuild this app
already uses for every other stream-parameter change, not a new
pause-in-place path) and swap the active driver. Ending one - the user
stopping it, or CAF's own SESSION_ENDED firing because the receiver
dropped off on its own - swaps back and calls the existing
loadVideo(itemId, driver.currentTime, !driver.paused) to resync,
reusing the exact path an audio/quality change or "Réessayer" already
uses rather than inventing a second one. A genuinely new reload for any
other reason (an item change, a burn-in/subtitle/stream-options change)
simply ends an active cast first rather than trying to keep a live
session in sync with every kind of party transition - judged not worth
the added complexity for v1; re-casting afterward is one click, and
CAF's ORIGIN_SCOPED auto-join policy means that click typically
reconnects to the same receiver instead of prompting a fresh device
picker. One related gap, also accepted for v1: reopening the same
party page after navigating away mid-cast (an SPA route change, not a
reload) doesn't try to detect and re-attach to a still-live session -
the new page mount just starts from local playback again.
Security, checked against the spec's own constraints: the real Emby
token never reaches the Chromecast (only the opaque castToken ever
appears in contentId, by construction - same as every other proxied
route); no second Emby stream is ever opened for a cast (it hits the
exact same party-keyed cache everything else does); a cast token is
only ever consulted by sessionForStream's 4 call sites - requireSession,
used by every other route, is untouched.
Verified: full typecheck/test/build on both projects: unit tests
for CastTokenService (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection) and for PartyService.leave()'s new
call. Confirmed against the real running server (no login needed for
this part) that the 4 streaming routes still reject a request with no
auth at all exactly as before, and now also reject one with an invalid
castToken - both a plain 401, no regression on the existing cookie
path. Confirmed in a real browser that the Cast SDK's <script> loads
and initializes correctly (window.cast/window.chrome.cast both
populated) with no console errors. Not verified: this sandbox has
no real Chromecast to finish a requestSession()/loadMedia() round
trip against, and no Emby login credentials to reach the player page at
all - so the actual on-TV playback, the cast button's live show/hide
behavior, and whether the Default Receiver truly renders this app's HLS
output cleanly are all unconfirmed, the same way this project already
flags its other can't-verify-without-the-real-thing assumptions. Worth
trying with a real device once deployed.
Casting a listen party
Asked for directly, after the video case above already shipped and was
confirmed working end to end: let a listen party cast too. The server
side needed nothing new at all - CastTokenService, the two
cast-token routes, and sessionForStream's fallback on the 4 streaming
routes were all already keyed on nothing more specific than partyId;
partyStreamUrl(partyId) was already exactly what musicBar/MusicBar .ts's own <audio> pointed at. This turned into an almost entirely
client-side feature: cast.ts's localDriver only needed its parameter
type widened from HTMLVideoElement to HTMLMediaElement (every member
it reads - currentTime/paused/duration/play()/pause() - is
already on the shared HTMLMediaElement base both elements implement),
and MusicBar.ts grew the same activeDriver indirection Player.ts
already has.
The one genuine design question was host-gating, and the answer is that
there isn't any to port: a listen party has no host at all (see "One
shared Emby stream per party" below) - any member's play/pause already
applies party-wide with no arbitration, the same symmetric model this
extends unchanged. Casting a track is exactly as symmetric: whoever
starts one is just routing their own copy of the already-shared audio
through a TV/speaker instead of this tab, same as plugging in
headphones would be, and the Chromecast's own on-screen controls feed
back into setMusicPaused exactly like the local <audio>'s native
play/pause events already do.
The other real difference from the video case: a video party's item
changes rarely enough that casting simply drops back to local playback
on any reload (see above) - "re-cast, one click" was an acceptable cost
there. A listen party's queue advances far more often, so dropping the
cast on every track would have made this a much worse feature than not
having it. Instead, a track change while casting calls the exact same
startCast() again, reusing the already-connected session -
requestSession() resolving immediately for a session that's still
open, no fresh device picker, is exactly what this needs and the video
path never had reason to exercise. The one wrinkle: partyStreamUrl
never encodes which track is playing (the server alone decides that),
so reusing the identical URL string for a new load gained a _=Date.now()
cache-buster - without it, a receiver that cached the previous track's
manifest would have had no reason to ever re-fetch it. If a re-cast
throws (the TV dropped mid-party, a network hiccup), it falls back to
local playback rather than stranding the party silently stuck - the same
recovery instinct loadTrack's existing try/catch around hls.js already
has for the same reason.
Leaving the party while casting ends the Chromecast session outright
(stopCast(), not just a driver swap back to local) - unlike Player.ts's
route cleanup, which deliberately leaves a cast running when you merely
navigate away from the page, actually leaving a listen party means there
is no more shared state left to cast at all.
Verified: full typecheck/build on the client (this app's client has
no test runner - see client/package.json - so client-side logic here
is typecheck+build+code-review only, consistent with the rest of this
codebase's client/server verification split). Not verified: no real
Chromecast or Emby login reachable from this sandbox, same standing gap
as the video case above - the actual re-cast-on-track-change round trip
needs a real device to confirm end to end.
The catalog API being slow, and the front freezing while it loads
Reported directly, still true after an earlier round already fixed
image caching (see EmbyClient.proxyResponse's own comment on that).
Two separate, compounding causes, both traced by reading the actual
request shapes rather than guessing:
CatalogService.getLibraryItems - the one query behind a whole library
grid, up to 300 items - asked Emby for Overview on every item and
never used it: views/library's tiles only ever render a poster, name,
and year (tiles.ts's tileHtml). Emby happily serializes a full
synopsis for all 300 anyway, and the client happily JSON.parses all of
it, purely to discard it unread - real, wasted latency on both the Emby
round trip and the response parse. Dropped from that query's Fields
(kept where it's actually shown - getSeriesEpisodes/
getPlaylistItemsRaw, read by views/series/views/playlist).
getArtists (a music library's own top-level grid) had no Limit at
all, unlike every sibling query - given a large enough library, an
unbounded artist list compounds the second cause below without even
Emby's own side being slow.
The literal "freeze" was separate from raw latency: tiles.ts's shared
render path built one HTML string for an entire batch of items and
assigned it via a single innerHTML write - fine for a home carousel
(Limit: 16), but a real, felt hitch on slower devices for a library
grid's up-to-300 (or previously-unbounded artist list). Fixed with
renderTilesInChunks - the same tiles, the same tileHtml, just
inserted 40 at a time across requestAnimationFrame callbacks instead
of one synchronous pass, so no single task blocking the main thread
grows with the list size. The one thing this couldn't stay naive about:
wireTileButtons used to attach one click listener per button at
call time, which would have silently never covered a tile that hadn't
rendered yet - switched to one delegated listener on the container
instead, which correctly covers whatever's in the DOM at click time
regardless of when it was inserted.
Verified: full typecheck/test/build on both projects (server
tests unaffected - neither changed query has a test asserting its exact
Fields string). The chunked-render mechanism itself was verified
directly - a synthetic 300-item list rendered against a real DOM in the
browser confirms every item lands and that a button in the last chunk
is still clickable through the delegated listener (the exact failure
mode a naive per-button-at-call-time wiring would have hit). Not
verified: no Emby login reachable from this sandbox to measure the
actual before/after latency against a real, large library.
Correction, reported immediately after this shipped: "the catalogs
are broken, not all the data is there". The Limit: 300 both queries
above carried (getArtists gained one here; getLibraryItems already
had one, predating this section) turned out not to be the safe,
theoretical headroom it was assumed to be - confirmed directly against
the real server that this app's own "Musique" library has 1229
distinct artists and "Films" has 421 movies, so both were silently
serving an incomplete grid, no error, no visible sign anything was
missing. Both Limits removed outright, not raised to some other
guessed number - confirmed empirically that Emby returns everything
when Limit is omitted at all (TotalRecordCount matched
Items.length exactly for both the 421- and 1229-item cases), and the
render-side concern that motivated capping in the first place is
already renderTilesInChunks's job regardless of how large the list
gets. That fix's own scheduling primitive needed a second look too:
requestAnimationFrame is deliberately paused by the browser for a
backgrounded/hidden tab - an ordinary thing to do while a now-possibly-
1000+-item list is still loading, and would have stalled the render
indefinitely, looking exactly like the same "data missing" symptom for
an unrelated reason. Switched to setTimeout(fn, 0), which keeps
running (throttled, never paused) regardless of tab visibility.
Fixing the listen-party queue's layout and its missing album art
Reported directly: the music bar's "File d'attente" popup looked broken, and the listen-party card on the home page never showed an image. A mockup was put together first (before touching any code) to confirm the direction, since the queue side was a genuine UI rework, not just a one-line fix.
The layout bug: MusicBar.ts's queue rows were reusing
styles/trackList.css's shared .trackRow - a CSS grid with 4 fixed
columns (28px 1fr auto auto) sized for views/album's own row shape
(track number, name, duration, an "Ajouter"/"Écouter ensemble" button).
The queue's rows never had a track number at all, and rendered
completely different content in that same slot - the track's own NAME
was the first child, so it landed in the 28px column meant for a
one-or-two-digit index, crushed down to its first couple of characters,
while the artist/album text spilled into the wide 1fr column instead
meant for the name. Fixed with a dedicated .queueRow (flex, not grid -
image, then a truncating info column, then the duration), rather than
trying to force this list's different shape through Album's grid with
more special-casing.
The missing images: confirmed directly against the real Emby server
(three sampled tracks, Fields=AlbumId,ImageTags) that an individual
Audio item essentially never carries its own Primary image - every one
tested answered 500, not even a clean 404 - while the same tracks'
parent albums mostly did (200 image/jpeg for two of three; the third
album genuinely has no artwork uploaded at all, a real data gap, not a
bug). Every place that built an image URL from a raw EmbyItem
independently reconstructed /api/catalog/image/${item.Id} by hand
(CatalogService.toItemDto, and three separate spots in index.ts's
party-creation routes that skip toItemDto entirely) - all now funnel
through one new CatalogService.imageUrlFor(item), which points a music
track at its AlbumId instead of its own Id when one's known.
EmbyItem gained the AlbumId field, and every Fields param that
lists Audio items among its results (getAlbumTracks,
getPlaylistItemsRaw, and defensively getRecentItems/
getLibraryItems) now requests it - a single-item detail fetch
(getRawItem, used by party creation) already includes it with no
Fields param needed, confirmed against the real server.
While fixing the home-page card's now-working image, its box turned out
to be the wrong shape for music too: .partyCard img was a fixed
48×72 portrait box - correct for a movie/show poster, but a hard crop
for an album's square cover. A video party's card is untouched; a
music party's now gets a 56×56 square box instead
(.partyCardArt--square, toggled from LobbyPartyDto.mediaType in
Home.ts).
Verified: full typecheck/test/build on both projects, including
new unit tests for imageUrlFor (a movie/episode unaffected, a track
with an AlbumId falls back to it, a track with none still falls back
to its own id rather than building a broken URL). No server is needed
to see the queue/card layout itself, so those were checked visually
against a mockup built from this app's own real theme tokens/typefaces
rather than a live instance.
Follow-up, reported later: the queue popup should span the bar's
full width, not sit in a fixed 480px column, and scrolling a long queue
scrolled its "File d'attente" header away with it. Both were the same
kind of oversight as the original layout bug above - CSS carried over
from when this was a small popup, not re-examined once the row shape
changed. The width cap (.musicBarQueue's max-width: 480px) was
simply dropped; the scroll region was the real fix - overflow-y/
max-height sat on .musicBarQueue itself, which wraps both
.pageHeader and #queueList, so scrolling the list scrolled the
header with it. Moved the scroll region down to #queueList alone
(.musicBarQueue becomes a plain flex column, header first with
flex-shrink: 0), the standard sticky-header-over-scrollable-body
split - the header now stays exactly where clicking "File d'attente"
put it regardless of queue length.
Reworking the player: a unified bottom bar, and a fullscreen that doesn't hide it
Asked for directly - a UI rework of the video player, "in both normal and fullscreen mode", with one concrete requirement: a viewer in fullscreen still needs to see the pause/resume request buttons. A mockup was built first (this app's own theme tokens, not a generic one) and iterated through a few rounds of feedback before any of this landed - the mockup itself ended up covering three things, not the two first asked for.
The actual fullscreen bug, found while investigating the request.
This app never had its own fullscreen button - the only way in was the
browser's native affordance on the bare <video> element itself
(double-click, or the host's own native <video controls> fullscreen
button - see becomeHost). Native per-element fullscreen only ever
renders that element's own subtree: every sibling overlay - the top
bar, presence avatars, and critically the request toolbar - is a
sibling of <video> in the DOM, so all of it disappeared the instant
fullscreen engaged. A follower in fullscreen had no way to reach the
host at all.
The fix: a new #fullscreenBtn that calls
playerWrap.requestFullscreen() - the whole player, video AND overlay
chrome - never video.requestFullscreen(). Since a native per-video
path is still reachable too (the same double-click/host-native-controls
gestures as before - nothing about those was removed, so the host keeps
native play/pause/seek/volume exactly as today), a document-level
fullscreenchange listener watches for document.fullscreenElement === video specifically and corrects it: exit, then immediately re-enter on
playerWrap instead. This runs regardless of which gesture triggered
it, so the overlay chrome is never actually lost for more than the
listener's own tick.
The bottom bar rework. What used to be three independent floating
pieces - a centered column (.bottomOverlay: the transient request
banner, a static "L'hôte contrôle la lecture" notice, a separate
Chromecast notice), a request toolbar cornered bottom-right, and an
"Épisode suivant" text link up in the top bar - are now one bar
(#bottomBar, three flex zones):
- Left: exactly one of
#nextBtn(host, now a real accent-green pill button with a skip-next icon, not a plain underlined link) or#requestToolbar(follower,#requestPauseBtn/#requestResumeBtn, each now labeled "Demander pause"/"Demander reprise" with a small icon). The two are mutually exclusive by construction - only the host ever has a queued item to advance, only a follower ever has someone else's playback to request against - so there's never a conflict for the same slot. - Center: a single
#ambientNoticeline, replacing the two separate static notices -updateAmbientNotice()shows "Diffusion en cours sur le Chromecast" while casting (checked first - it's the more useful thing to know in the moment), else "L'hôte contrôle la lecture" for a follower, else nothing for a host who isn't casting. - Right:
#fullscreenBtn, for everyone, always.
The transient request banner stays independent, near the top rather
than folded into the persistent bar - it's a pop-in/fade-out toast
(showRequestBanner), and merging it into a bar that's otherwise always
present would have made it read as just another static line rather
than something that needs attention right now.
A visible cue for casting. #castingScreen now covers the video
area whenever isCasting is true - the local <video> is paused/torn
down while casting (see "Casting to a Chromecast" above), so leaving
its last frame sitting there with no explanation would just look
stuck. A plain icon + "Diffusion sur le Chromecast", not an attempt to
mirror the TV's own picture.
Verified: full typecheck/build on the client (no server changes at
all - this is pure client markup/CSS/TS). Checked in a real browser
against the actual compiled app.css/bundle (not just the mockup) -
injected the new markup with its real classes for the host view, the
follower view (with the toast), and the casting view, and confirmed
each renders as designed; caught and fixed one real sizing bug this
way (.fsBtn grew to 32px instead of its declared 30px - a flex item's
default min-width: auto was letting button's own base 12px/16px
padding win over the smaller explicit width; fixed with an explicit
padding: 0 on the icon-only button). Not verified: the Fullscreen
API itself needs a real user gesture and an actual browser fullscreen
transition, neither of which this sandbox can exercise - worth trying
for real once deployed, same as this project's other can't-verify-
without-the-real-thing items.
The player's bottom bar was really two bars: replacing native <video controls>
A direct follow-up to the player rework above, reported right after
trying it for real: "two z-index layers, some elements duplicated,
only in fullscreen." The actual cause was never a bug in the new
bottom bar itself - it's that video.controls = true (set for the host
since the very first version of this player) has always rendered the
browser's own native control bar, a separate piece of UI that lives in
its own always-on-top compositing layer, entirely independent of this
app's DOM/CSS. That was never visually obvious before: fullscreen used
to show only the video (the bug the previous section fixed), so the
native bar was the only thing there was to look at. Once fullscreen
correctly started showing this app's own overlays too, the native bar
and the custom one both became visible at once - and specifically
duplicated, not just crowded: Chrome adds its own cast icon to a
<video>'s native controls automatically once it detects a Cast-
capable device, entirely separate from #castBtn's own integration
(cast.ts) - "some elements duplicated, others not" was that icon
sitting right next to this app's own, while native play/pause/seek/
volume simply had no custom equivalent yet to look duplicated
against.
Two fixes, asked for as one:
disableremoteplaybackon<video>- the standard attribute for exactly this situation (a page with its own Cast integration telling the browser not to add its own). Should have been there from the start of "Casting to a Chromecast" above; this is where it was actually noticed missing.- Native
<video controls>retired entirely, replaced with a custom transport bar - not just for fullscreen, everywhere, so there is only ever one control surface instead of two that happen to overlap less in the normal (windowed) case.PlaybackDriver(cast.ts) gaineddurationalongsidecurrentTime/paused- the one property the existing abstraction didn't yet expose, needed for a seek bar to compute a fraction or a target time regardless of which driver is currently active.
What #bottomBar gained: a seek bar (#progressBar/#progressFill)
on its own row above the existing button row - column layout, so the
two stack in normal flow rather than two independently-guessed fixed
offsets (the same reasoning .topOverlay's actionBar+presenceOverlay
grouping already used). Host: pointer events (not click/drag, so mouse
and touch share one implementation) seek by fraction of
activeDriver.duration; a follower's bar is the same element, just
read-only (.readOnly - a cursor/visual hint only, the actual pointerdown
handler checks isHost itself regardless). A progressTicker
(500ms) keeps the fill/time label moving between discrete events, since
currentTime changes continuously while playing with no event of its
own for that. #playPauseBtn (host-only, sharing the request buttons'
left zone with #nextBtn - never more than one of the three visible at
once) and #muteBtn (everyone, local-only - video.muted directly,
never synced, since unlike play/pause this has no party-wide meaning at
all - hidden while casting instead of controlling a local <video>
that's already silent then) round out the replacement.
One function, onDriverStateChanged, threads through every discrete
event that used to just call sendLocalState directly - the local
<video>'s own native play/pause/seeked events, and a cast session's
RemotePlayerController events (startCast's own onRemoteStateChanged
callback) - so the button icon, the seek bar, and the party broadcast
stay in lockstep regardless of which driver or which gesture caused
the change, the same discipline every other party-wide state in this
app already follows.
Verified: full typecheck/build on the client. Checked in a real
browser against the compiled bundle - injected the new bottom bar
markup for both a "playing, unmuted" and a "paused, muted" state and
confirmed the icons/fill/time label/readOnly cursor all render as
designed. Not verified: an actual drag-to-seek gesture and real
play/pause/volume behavior against a real <video> with a real source -
this sandbox has no reachable Emby stream to attach one to, so the
event wiring is confirmed by reading, not by dragging a real seek bar.
Worth exercising for real once deployed.
The bottom bar auto-hides down to just the seek bar
Reported right after the previous section shipped: the bottom bar,
now always visible, sits exactly where a native <track> subtitle
renders (applySubtitleTrack) - and was masking it. The standard fix
every real video player already uses: collapse to a minimal state
after a stretch of no activity, expand again on the slightest
interaction.
#bottomBar gets a .minimal class (scheduleControlsHide,
CONTROLS_HIDE_DELAY_MS = 3s of no activity) that transitions away its
padding/background/border and the buttons row's height/opacity -
#progressBar itself is untouched by any of this (including its own
generous invisible hit-area for seeking), so a thin seek line is always
there, just without the box around it once minimal. Expands again on
any pointermove/pointerdown anywhere in .playerWrap, or while the
pointer is sitting on the bar itself (mouseenter/mouseleave pause
the countdown entirely rather than just resetting it, so hovering the
bar can never lose a race with its own timer). Stays expanded outright
while genuinely paused - nothing playing to hide the bar from - and
every discrete playback event (onDriverStateChanged) re-arms the
countdown too, not just raw pointer activity, so resuming from a pause
correctly restarts it rather than leaving the bar stuck open forever.
Applies to everyone, host or follower alike - the bar (and the
subtitle-masking problem) exists for both.
Verified: full typecheck/build. Confirmed in a real browser against
the compiled bundle, side by side with an actual subtitle-shaped
overlay in the same position: the expanded bar visibly overlaps it,
the .minimal state (just the thin progress line) leaves it fully
readable. The timer itself (does it actually fire after 3s of real
inactivity, does hovering genuinely prevent it) wasn't driven through
real wall-clock waits here - the CSS transition and class-toggling
logic were checked directly instead.
Reported broken on mobile, right after shipping: it never actually
auto-hid there. Root cause - a classic mobile-web trap, not a typo:
mouseenter/mouseleave on the bar tracked "is the pointer resting on
the controls" so hovering could hold them open, but a touch pointer has
no real hover - tapping the bar fires a synthetic pointerenter with
no pointerleave ever coming after it (there's no cursor left to lift
away), so the very first tap anywhere on the bar latched
isPointerOverBottomBar permanently true and the bar could never
auto-hide again for the rest of that session. Fixed by checking
event.pointerType === 'mouse' in both handlers - touch/pen never even
attempt the "hovering holds it open" path at all, and correctly fall
back to the same plain activity-based wake-up (pointerdown/
pointermove on the whole player) every device already gets. Confirmed
by reproducing the exact failure directly - dispatched a synthetic
touch pointerenter with no matching pointerleave (the real-world
case) against the fixed logic and watched it still transition to
.minimal on schedule, where the original mouseenter-based version
would have latched open. A real touchscreen's own event behavior still
isn't independently reproducible here, so this is the strongest
confirmation available without one.
Playlists: "Lire à partir d'ici"
Asked for directly: alongside "Lancer cette playlist" (always from the
first item), a way to start a playlist party from any chosen item.
views/playlist/Playlist.ts's own top comment used to rule this out -
written back when the page was deliberately read-only, reasoning that
"start from item N, queue the rest" wasn't reconstructable there the
way views/series does it for episodes, since (unlike an Episode's own
SeriesId) a playlist item carries no back-reference to whichever
playlist it came from.
That reasoning was correct for a bare item id on its own - but doesn't
actually block this page specifically: renderPlaylist already fetches
the entire, ordered item list for getPlaylistItems to render the
list at all, so "item N of playlist P" only ever needed playlist P's
own already-in-hand list, never a back-reference on the item itself.
views/library's tiles and views/album's per-track rows still can't do
this (neither has that ordered list around) - only this exact page can,
which is why the feature lives here and nowhere else.
createParty(itemId, startAtItemId?) (client/src/api/party.ts) gained
the optional second argument, threaded through to POST /api/parties's
body and on into createPartyFromPlaylist (index.ts) - meaningless
(silently ignored) for every other item type, so every other existing
call site keeps working unchanged with just one argument.
createPartyFromPlaylist resolves it against the playlist's own
(post-splitPlaylistByMediaType) item list and slices from there -
allItems.findIndex(...) then .slice(startIndex), the exact same
"slice, don't wrap" shape POST /api/parties' own single-item path
already uses for item.SeriesId-based episode starts, just applied to
a playlist's item list instead of a series' episode list. Everything
before the chosen item is simply left out of this party, not queued
for later - true for both video (the fixed queue is built from the
sliced list) and music (the sliced list's own first item seeds the
queue, the rest is added after it, same as launching a playlist always
worked, just from a different starting point).
Client-side, each row in #itemList gets its own "Lire à partir d'ici"
button now (.startFromBtn, mirroring views/series' own
.startEpisodeBtn - reuses .episodeRow's existing button styling/
responsive wrap with no new CSS at all), alongside the unchanged
"Lancer cette playlist" button at the top. Both funnel through one
shared launch() helper - only the button, its busy/idle label text,
and whether a starting item id is passed differ; create-then-dispatch-
on-mediaType-then-error-handling is identical either way.
Verified: full typecheck/test/build on both projects (118/118
server tests - no dedicated test added for createPartyFromPlaylist
itself, consistent with this project's existing boundary that route
glue in index.ts isn't unit-tested, only the services it calls into
are). Confirmed in a real browser against the compiled bundle that the
new per-row button renders correctly alongside the existing controls,
at both desktop and phone width (the phone-width wrap is the same
@media rule views/series already relies on, untouched here).
Casting broke after the first segment: the rewritten playlist URLs never carried the castToken
Reported directly: "for cast, the .m3u8 needs to be passed instead of a
raw [url]". The master playlist itself was never the problem - POST .../cast-token mints the token, and the initial master.m3u8?castToken=...
request already carries it. What broke immediately after: every URI
line inside that playlist gets rewritten (EmbyClient.rewritePlaylist)
to route back through this app's own party-stream/:partyId/raw?u=...
proxy instead of hitting Emby directly - and that rewrite never carried
the castToken forward at all. A browser doesn't notice, because it's
still sending its session cookie on every one of those follow-up
requests regardless of what's in the URL. A Chromecast has no cookie to
fall back on (see CastTokenService) - so its segment/variant-playlist
fetches hit sessionForStream with no auth of any kind and 401'd,
breaking playback moments after the receiver appeared to connect.
Fixed by threading the same castToken the master-playlist request
itself carried into every rewritten line - rewritePlaylist(text, routeKey, castToken?), sourced from req.query.castToken right inside
proxyResponse (which already receives req, so no new parameter
needed anywhere upstream of it). Omitted entirely when there wasn't one
(the normal browser/cookie case - unaffected either way, since a
browser was never relying on this).
The self-hosted burn-in path (TranscodeService) had the exact same
gap for a different reason: ffmpeg's own HLS muxer writes bare relative
segment filenames into its master.m3u8, which a browser resolves
against the request's own URL (keeping its cookie without even
noticing) but which - per ordinary URL-resolution rules, not a bug in
ffmpeg or this app - silently drops any query string when resolved
against a base URL that had one. Fixed the same way: a new pure
appendCastTokenToSegments(text, castToken) (extracted specifically so
this could be unit-tested directly, unlike serveMasterPlaylist itself
which needs a real ffmpeg session and on-disk files to exercise at all)
appends ?castToken=... to each segment line when a request arrives
with one; the plain fs.createReadStream(...).pipe(res) fast path is
untouched when it doesn't.
Verified: full typecheck/test/build (122/122 server tests,
including new coverage for rewritePlaylist's token propagation - had
no test at all before this, for the rewriting itself or the token fix -
and for appendCastTokenToSegments). Not verified: an actual
Chromecast fetching real rewritten segment URLs - no device reachable
from this sandbox, same standing limitation as the rest of "Casting to
a Chromecast" above.
The player toolbar still wasn't auto-hiding: a heartbeat and its own hide-timer, both every 3s
Reported right after the mobile touch fix: "the toolbar still doesn't
work, mobile and PC" - meaning the other auto-hide fix hadn't
actually landed for anyone, not just touch devices. Root cause this
time: an actual bug in this session's own previous edit, not a browser
quirk. becomeHost's 3-second heartbeat (sendLocalState, needed
regardless of any real activity, so a follower's periodic resync never
goes stale) got rewired to call the same onDriverStateChanged the
native <video>'s real play/pause/seeked events use - which also
re-arms the auto-hide countdown (scheduleControlsHide), on the
reasoning that resuming from pause needed to restart it. That reasoning
was correct for a genuine discrete event; the heartbeat is not one - it
fires unconditionally every 3s whether or not anything changed. With
CONTROLS_HIDE_DELAY_MS also 3000ms, every single heartbeat tick
cancelled and restarted the hide countdown from zero, on the same
cadence the countdown itself needed to run uninterrupted just to reach
its own deadline - so for a host, on any platform, the bar could get
re-armed indefinitely and effectively never complete a hide. A follower
(no heartbeat of their own) was never affected by this specific bug -
only by the earlier touch one.
Fixed by giving the heartbeat its own function, reportHeartbeat -
same state sync (button icon, seek bar, sendLocalState) as
onDriverStateChanged, deliberately without the
scheduleControlsHide call. Every genuine discrete event (native
video play/pause/seeked, a cast session's remote-state changes, a
manual seek) still goes through onDriverStateChanged unchanged and
still correctly re-arms the countdown.
Verified: full typecheck/build. Confirmed directly (not by racing two
real timers against each other, which only proves something over an
observation window long enough to be impractical here) that 3 simulated
heartbeat ticks call the hide-timer's reset function zero times, while
one simulated discrete event still calls it exactly once - the precise
behavioral difference the fix is supposed to produce.
Cast still didn't play at all: no CORS on the streaming routes
Reported again after the castToken-propagation fix above: "still doesn't work, the stream doesn't play on the TV side." That fix was real, but not the whole story - it addressed what would break after the first segment; this was more fundamental, blocking things from the very first request.
The Default Media Receiver plays this app's HLS through an MSE-based
player (Chromecast's own Chromium build has no native HLS support to
fall back on) - which fetches every segment via fetch/XHR from
whatever origin Google hosts that receiver at. Cross-origin, from this
app's own server. This app never sent a single CORS header anywhere -
never had to, every consumer until now was this app's own same-origin
page. Without one, the browser blocks every one of those cross-origin
reads outright, before any of this app's own cookie/castToken check
even gets a chance to run - the master playlist request itself included.
Fixed with a small allowCastCors middleware on the same 4 streaming
routes sessionForStream already covers (party-stream/:partyId/ master.m3u8/raw, burn/:partyId/master.m3u8/:segment) -
Access-Control-Allow-Origin: *, plus handling OPTIONS for the same
4 paths (a real cross-origin Range header - needed for byte-range/
segment fetches - isn't itself CORS-safelisted, so it triggers a real
preflight, which this app previously had no answer for at all). * is
safe specifically because these routes never need to support
credentialed cross-origin access: a real browser already reaches them
same-origin (no CORS involved), and the one genuine cross-origin caller
authenticates via the castToken query param, never a cookie -
Access-Control-Allow-Origin: * combined with
Access-Control-Allow-Credentials would be required before a cookie
could ever ride along cross-origin, and this deliberately never sends
that second header at all.
Verified: full typecheck/test/build. Confirmed against the real
running server that an OPTIONS preflight on these routes now answers
204 with the right three headers, that a real (still-401, no token
given) GET still carries Access-Control-Allow-Origin regardless of
outcome (so a cross-origin caller's own JS can actually read that
response rather than seeing an opaque failure), and that an unrelated
route (/api/auth/me) carries no such header at all - this stays
scoped to exactly the 4 routes a Chromecast needs. Not verified:
whether this is now sufficient for a real receiver to actually decode
and play the stream end to end - no device reachable from this
sandbox, same standing limitation as the rest of "Casting to a
Chromecast".
A sticky search bar, and letting anyone seek within a playing track
Two small, unrelated requests handled together.
The search bar on views/library/views/artist/views/album
(tiles.ts's shared .searchBar) scrolled away with the page - asked
to stay reachable while scrolling a long grid instead. position: sticky; top: 0 on the input itself, not a wrapper - base.css's own
generic input rule already gives it an opaque var(--panel)
background, which is what actually keeps tiles scrolling underneath
from showing through its bounds once stuck; nothing else needed
changing.
Seeking within the current music track was explicit, deliberate
scope this session's own "participative playlist" work had cut - the
progress bar was marked read-only specifically because, at the time,
MusicQueueService had "no seek concept... just play/pause/advance".
Asked for directly now. The permission model was already decided,
though, by that same class's own documented reasoning for why anyone
(not a vote, not a host) can pause/resume: it's reversible and doesn't
change what's playing - a seek is exactly that same shape, just
continuous instead of binary, so it gets the identical treatment, not
voteSkip's majority gate (which changes what's playing).
MusicQueueService.seek(party, positionSeconds) mirrors setPaused's
own elapsed-time math (clamped into [0, durationSeconds] when known),
with one real difference setPaused's resume branch never has to deal
with: scheduleAdvance only ever sets advanceTimer, it never clears
a prior one - resuming gets away with calling it directly only because
pausing already cleared the timer first. Seeking while still playing
has no such guarantee (the old timer, counting down against the old
position, is still live) - skipping the clear first would leave it
running alongside the new one, eventually firing playNext at the
wrong moment. New musicSeek socket event, no host check (same as
musicPlaystate). Client-side, musicBar/MusicBar.ts's progress track
gained the same pointerdown/move/up drag-to-seek handlers the video
player's own seek bar already has - no host branch to port over this
time, since a listen party has none - and a local, optimistic
activeDriver.currentTime write before broadcasting, for the same
instant-feedback reason the video player's own seekFromPointerEvent
does that.
Verified: full typecheck/test/build on both projects (128/128
server tests - new coverage for seek's clamping, its paused-vs-playing
branches, and specifically a real-timer test proving the old advance
timer is cleared rather than left running alongside a re-armed one, the
exact bug the naive "just call scheduleAdvance again" version would
have shipped). No server is needed to see the sticky search bar itself,
so that was checked by reading .mainContent's own layout directly
(no nested scroll container between it and the document, confirming
position: sticky resolves against the page scroll as intended, not
some inner element).
Skipping the intro/end credits, using Emby's own native detection
Asked for directly: propose skipping a TV episode's opening/end credits,
Netflix-style. Checked directly against the real server before building
anything, rather than assuming a data source existed - no plugin
provides this (the installed "Cinema Intros" plugin is a different
feature entirely - pre-show trailers, not per-episode skip points), and
the embedded Chapters Emby already returns turned out to be
auto-generated placeholders every 5 minutes ("Chapitre 1", "Chapitre
2", ...), not real markers.
What the server DOES have, confirmed from its own /openapi.json
rather than assumed: a native, per-library EnableMarkerDetection
option (Library/VirtualFolders's LibraryOptions) backed by a
built-in "Detect Episode Intros" scheduled task - audio-fingerprint
detection, the same idea as Jellyfin's separate Intro Skipper plugin,
just already part of Emby itself. Once enabled and run, it adds real
entries to the exact same Chapters array, distinguished from the
auto-generated ones by MarkerType: IntroStart/IntroEnd/
CreditsStart (confirmed from the schema, not guessed - there is no
CreditsEnd, since credits are assumed to run to the end of the file).
Given that, this feature is almost entirely "read what Emby already
computed and expose it" - no fingerprinting, no new persistence, no
background jobs of this app's own. CatalogService.getPlaybackMarkers
fetches Fields=Chapters for an item and picks out just those three
marker types (nulls when detection hasn't reached this item, or isn't
enabled for its library - never an error), behind a new
GET /api/catalog/item/:id/markers route, mirroring
getPlaybackOptions's own shape exactly.
The player side adds one new floating button, #skipIntroBtn -
explicit label ("Passer l'intro"), not an icon, matching this app's own
established convention for every request-shaped control. It's
positioned as a sibling of #bottomBar, not inside it, specifically so
it stays reachable through the bar's own auto-hide/.minimal collapse -
someone who hasn't touched the player still needs to see it the moment
the intro starts. updateSkipButton (ticked every 500ms alongside
updateProgressBar) compares activeDriver.currentTime against the
fetched markers and shows/hides it; clicking it seeks straight to
introEndTicks through the exact same activeDriver/onDriverStateChanged
path any other seek already uses, so it's automatically host-only (a
follower's seek never broadcasts - see sendLocalState's own gate) and
automatically cast-aware (works identically whether activeDriver is
the local <video> or a Chromecast's RemotePlayer) with no new
special-casing for either.
End credits deliberately got no button of their own: #nextBtn already
does exactly "go to the next queued item" and was already shown
whenever hasNextQueued is true, for the whole episode - not something
this feature needed to duplicate. The only real gap it closes is
visibility: if nobody's touched the player in a while, #bottomBar
(and #nextBtn inside it) is collapsed to .minimal right when
credits start. updateSkipButton calls the existing showControls()
once (a creditsControlsShown flag keeps it one-shot, not a fight
against someone deliberately re-minimizing the bar during a long
credits scroll) the moment playback crosses creditsStartTicks, so the
already-existing button is actually reachable without requiring a mouse
nudge first.
Verified: full typecheck/test/build on both projects (125/125
server tests, including new coverage for getPlaybackMarkers - the
real MarkerType values mixed with auto-generated Chapter ones, and
both "no Chapters field" and "no markers among the Chapters that are
there" resolving to nulls rather than throwing). The button's on-screen
position was checked directly (getBoundingClientRect against the real
compiled CSS, not eyeballed) - it sits clear of #bottomBar with a
10px gap at both a 960×540 and a phone-width viewport, since both use
fixed pixel offsets off the same .playerWrap. Not verified: the
detection task had only just been enabled on the real server (confirmed
via its own EnableMarkerDetection/ScheduledTasks state) and hadn't
finished analyzing any episode yet at the time this shipped, so the
actual end-to-end "does a detected marker make the button appear at
the right second" behavior is unconfirmed against real generated data -
worth a real check once the task has had time to run.
Making Emby's own admin dashboard see watch-party streams
Asked for directly: Emby's admin "now playing" dashboard should see
this app's streams the same way it sees a real web/mobile/TV client's -
this is exactly the follow-up "Sync watch-party progress back to Emby"
(above) already anticipated and deliberately deferred, word for word:
"wiring clean start/stop into every transition... is real complexity
for a secondary payoff, left as a documented follow-up rather than
built speculatively." That section also already explains why: this app
switched entirely to direct UserData writes for resume-position/
watched-history, abandoning Emby's session-shaped PlayingItems
endpoints, precisely because they need a real session lifecycle to mean
anything - and given that section's own history (two separate rounds of
a wrong-but-schema-plausible request shape for that other,
now-abandoned endpoint family), this one was built carefully, checking
the real API before writing anything rather than assuming.
Confirmed against the real server's own /openapi.json (not
assumed) that the actual session-lifecycle family is POST /Sessions/Playing (start), /Sessions/Playing/Progress, and
/Sessions/Playing/Stopped - distinct from the PlayingItems family
PlaystateService already walked away from. None of the three schemas
declare a required list at all, which - given this exact neighboring
history - was read as "don't trust that," not "everything's optional
in practice": ItemId/MediaSourceId/PlaySessionId/PositionTicks
are sent on every call anyway, matching what a real client obviously
would, rather than risking a repeat of the earlier "technically matches
the schema, still 400s" experience.
One real Emby session per PARTY MEMBER, not one per party - a
faithful copy of how a real client behaves (every device/account gets
its own), and the actual point of "let admin see who's watching what"
would be lost by collapsing every member onto one shared entry. New
server/src/services/EmbySessionService.ts (start/progress/stop)
mirrors PlaystateService's own shape closely - same best-effort
philosophy (every failure caught and logged, never thrown), same
Logger.playstate category (broadened to describe both) - but is kept
as its own class rather than folded into PlaystateService, since that
class's entire opening comment is specifically about why it doesn't
use a session-shaped endpoint; mixing the two would muddy exactly the
distinction that comment goes out of its way to draw.
Getting a MediaSourceId/PlaySessionId per member needed a new
CatalogService.negotiatePlaybackSession(session, itemId) - a fresh
PlaybackInfo negotiation under that specific member's own account.
This can't reuse PartyStreamService's existing shared negotiation:
that one is negotiated once, under whichever member happened to
trigger it first, and Emby scopes PlaybackInfo per user - never
guaranteed valid for reporting under everyone else's own account too.
Returns null on any failure rather than throwing (an expired token,
Emby unreachable, no source) - unlike getMediaSource's own hard
failure (which feeds a real ffmpeg source and has to fail loudly), this
one only ever feeds an admin-dashboard nicety.
Driven entirely from index.ts's existing periodic sweep, not new
hooks scattered across every queue-advance/track-skip call site the way
PlaystateService.markPlayed needed. That sweep already visits every
connected member on every ~20s tick - exactly positioned to notice
"this member's current item no longer matches PartyMember .embySession.itemId" and react: stop the old session if there was
one, negotiate fresh ids, start the new one. The same item as last tick
just sends a Progress report reusing the already-cached ids - no
renegotiation needed for the common case. PartyMember gained the
nullable embySession field this reads/writes (same null-until-known
shape as embyToken/deviceId). Explicit stop calls also happen at
the two places a member's session should end promptly rather than
waiting on the next sweep tick to just stop reporting: PartyService .leave() (alongside the existing CastTokenService.revokeForMember)
and removeIdleParties (a defensive backstop for a member whose own
disconnect-driven leave() never fired for some reason - a real gap
would otherwise leave a stale dashboard entry indefinitely, not just
until the next tick).
Verified: full typecheck/test/build (140/140 server tests - new
coverage for negotiatePlaybackSession's null-on-any-failure
contract, every EmbySessionService method's exact request shape and
that a non-ok response/thrown error is swallowed not propagated, and
that leave() calls stop with a member's real cached session info
but only when they actually had one open). Not verified: an actual
live write against the real server - deliberately not attempted from
here, since doing so would mean using a real account's Emby token for
a genuine state-changing call, beyond the read-only diagnostic use
this session's shared API key was ever meant for. So while the request
shape is checked against the real schema and the per-member logic is
unit-tested in full, whether Emby's dashboard actually displays these
sessions the way a native client's would - correct device/client
labeling in particular, since this app never sends the X-Emby- Authorization Device/Client/Version header a real client would, only
X-Emby-Token - is genuinely unconfirmed. Worth watching the real
dashboard the first time a party runs after this ships.
Picture-in-picture
Asked for directly, mobile in particular: a phone has no second window
to put this app in the way a desktop might, so the standard browser
Picture-in-Picture API - popping the <video> out into a small,
OS-managed floating window - is the one way to keep watching while
switching to another app. Standard HTMLVideoElement .requestPictureInPicture()/document.exitPictureInPicture(), not the
Chromecast-adjacent disableremoteplayback trick or anything WebKit-
prefixed - confirmed directly in a real Chromium build that
document.pictureInPictureEnabled/requestPictureInPicture/
exitPictureInPicture are all present, which is what actually matters
for Android Chrome, the mobile browser this was asked for.
New #pipBtn, right next to #fullscreenBtn (.bottomBarZone.right)
- both are "how is this displayed" toggles, so they share the same
.fsBtnsizing, and a new.fsBtn[aria-pressed="true"]rule gives PiP the identical "clearly on" orange treatment#castBtnalready has (kept as its own rule rather than combined onto.iconBtn- that class and.fsBtndisagree on padding/border-radius/background, so combining them risked an inconsistent size instead of a clean shared look). Feature-detected once at mount (document.pictureInPictureEnabled && !video.disablePictureInPicture), not live the way#castBtn's ownonCastAvailabilityChangedis - browser support for this doesn't appear/disappear mid-session the way a Chromecast on the network does, so there's nothing to react to after the first check. No new attribute needed on<video>itself either - unlikedisableremoteplayback(added specifically to suppress Chrome's own auto-injected cast icon), there's no equivalent native PiP affordance this app needs to suppress.
State (aria-pressed) is kept in sync via the video's own
enterpictureinpicture/leavepictureinpicture events rather than the
button's own click handler alone - covers the browser's native PiP
window being closed directly (its own close control, or the OS), not
just a second click back in this app. Requires no cleanup on route
navigation: like every other per-mount element here, <video> itself
is destroyed and replaced by the next root.innerHTML = html (router.ts
runs this unconditionally on any navigation) - browsers already close
an active PiP window automatically once its source element leaves the
document, matching this app's very deliberate opposite choice for
casting (leaving an active Chromecast session running after
navigating away - see "Casting to a Chromecast" above): PiP has no
independent existence apart from the <video> element it mirrors, a
Chromecast session does.
Verified: full typecheck/build (client only - this is a pure
client-side feature, no server involved). Confirmed directly in a real
browser, not just type-checked: document.pictureInPictureEnabled is
true here, and running the exact show/hide + click-handling logic
against a real (sourceless) <video> correctly shows the button and
catches the resulting InvalidStateError from requestPictureInPicture()
without it becoming an uncaught exception - the same rejection a real
video would throw for a different reason (already in PiP, or the
document not focused), confirming the error handling path itself
rather than just assuming it. Not verified: an actual floating PiP
window on a real mobile browser, playing a real Emby stream - no
device/login reachable from this sandbox, same standing gap as this
project's other browser-native integrations (casting, the intro-skip
buttons).
Queuing a whole album, or an artist's entire discography, in one action
Asked for directly: views/album's and views/artist's per-track/
per-album "Écouter ensemble"/"Ajouter à la file" buttons had no
whole-album or whole-discography equivalent - queuing an album meant
clicking every track in it one at a time, and an artist page had no
listen-party action at all beyond drilling into a specific album.
Server-side resolution, not a client-side loop - the same choice
already made for playlists (createPartyFromPlaylist), and for the
same reason: an artist's "entire discography" can genuinely be dozens
of albums and hundreds of tracks, and a client-side loop of individual
addTrackToQueue calls would mean that many sequential round trips
(slow, and non-atomic - a network hiccup partway through leaves a
partial queue) instead of one. New resolveMusicTracksForItem(session, item) (index.ts) takes a raw Emby item already known to be
Audio/MusicAlbum/MusicArtist and resolves it to a flat, ordered
MusicTrackInput[] - a single track for Audio, every track in Emby's
own IndexNumber order for a MusicAlbum
(CatalogService.getAlbumTracks), every album's tracks (same order)
for a MusicArtist's own albums (getArtistAlbums, itself already
sorted ProductionYear,SortName - i.e. chronological), fetched with
one Promise.all rather than sequentially. POST /api/parties gained
a MusicAlbum/MusicArtist branch, right alongside the existing
Playlist one, calling this and a new shared createMusicPartyFromTracks
- itself just
MusicQueueService.initfor the first track plus the newaddTracks(below) for the rest, extracted socreatePartyFromPlaylist's own music branch could be simplified onto the exact same two calls rather than keep its own separate per-track loop.
MusicQueueService.addTracks is the batch sibling of the existing
addTrack (both now share a buildTrackEntry helper for the actual
per-track shape) - same auto-starts-if-the-queue-was-empty behavior,
but one queueUpdated broadcast for the whole batch rather than one
per track. That distinction is what a naive "just call addTrack in a
loop" version would have gotten wrong: for a hundred-track discography,
that's a hundred intermediate broadcasts to everyone in the party for
what is, from a user's perspective, a single action.
Adding to an already-live party's queue needed its own new route,
POST /api/parties/:partyId/queue - given just an itemId, it resolves
it the same way (resolveMusicTracksForItem) and calls addTracks.
Kept separate from the existing POST .../tracks, which takes an
already-built track object a caller already has in hand (views/album's
own per-track buttons, which never needed this resolution step at
all) - overloading that route to guess whether it received a full
track or a bare id to resolve would have been more surprising than a
second, clearly-named one.
The buttons themselves mirror views/playlist's own "Lancer cette
playlist" exactly - same label-reflects-current-state-and-re-checks-on-
click discipline views/album's per-track buttons already established,
same "attach the bar and stay on the page" behavior when starting fresh.
views/album gained one page-level button (.playlistActions, reused
from Playlist.css the same way Album.css already reuses .seriesInfo
from Series.css - one CSS bundle regardless of which view's file a
rule was first written in, so nothing needed duplicating). views/artist
gained its first listen-party action ever, sitting in .pageHeader
itself (its own justify-content: space-between already puts a second
child on the right of the title with no new CSS needed).
Verified: full typecheck/test/build on both projects (147/147
server tests - new coverage for addTracks: batch order preserved,
auto-start only fires for the batch's own first track when the queue
was empty, exactly one broadcast regardless of batch size, and a
harmless no-op for an empty batch). resolveMusicTracksForItem/
createMusicPartyFromTracks themselves are index.ts route glue, not
independently unit-tested - consistent with this project's existing
boundary that route-level orchestration isn't, only the services it
calls into are (same reasoning createPartyFromPlaylist was never
tested directly either). The two new buttons' layout was checked
visually against the real compiled CSS (a static render, real theme
tokens) rather than eyeballed. Not verified: an actual large
discography queuing correctly end to end against a real server - no
Emby login reachable from this sandbox.
Progressive Web App
Asked for directly, as a follow-up to Picture-in-picture above: since
that feature's automatic-on-backgrounding variant turned out not to be
achievable (browsers require a fresh user gesture for
requestPictureInPicture() - confirmed empirically, not just from
docs, by calling it from a real visibilitychange listener in a real
Chromium tab and getting NotAllowedError even with
navigator.userActivation.hasBeenActive === true from an earlier real
click), a PWA install was asked for instead. Worth being explicit
about, since it'd be easy to assume otherwise: installing this app
does not change that outcome - the transient-activation requirement is
part of the Picture-in-Picture spec itself, not something tied to
being loaded in a browser tab versus an installed, display: standalone
window. The one Chrome feature that skips that requirement
(media-session-handler-based automatic PiP) stays desktop-only and
getUserMedia-gated regardless of install state, and nothing above
touches that gate. So the manual #pipBtn toggle remains the way to
enter PiP either way - a PWA install is its own, independently useful
feature here (an icon on the home screen, no browser chrome/URL bar
around the player, an OS-level app-switcher entry), not a workaround
for the previous section's limitation.
Three pieces, all standard and Chromium/Android-focused since that's this app's stated mobile target (see Picture-in-picture above):
client/src/public/manifest.webmanifest-name/short_name,display: "standalone", andbackground_color/theme_colorcopied by hand frombase.css's--bg/--bg-header(a manifest can't read CSS custom properties, so these two are duplicated on purpose - if that palette ever changes, update both places).start_url: "/",lang: "fr"matchingindex.html's own<html lang="fr">. Noservice worker- confirmed against MDN's own installability requirements that Chrome does not require one for the install prompt, and this app has no real use for one: every page is either live party/socket state or a proxied Emby catalog, and caching either offline would mean showing stale library data or a stale party that's already gone, not a working offline mode - so skipping it avoids a whole stale-cache-invalidation problem this app doesn't need to solve.- Icons - hand-authored SVG (this project has no existing
logo/brand asset), rasterized to PNG at the sizes each consumer
needs (192/512 for the manifest, 180 for
apple-touch-icon, 32 for the browser-tab favicon) via a<canvas>in a real browser rather than an image-editing tool, since that's what was available here. Two source SVGs, not one:icon.svgfills the canvas (used for the manifest's"any"purpose, the favicon, andapple-touch-icon- none of those get OS-mask-cropped) andicon-maskable.svgscales the same design down (~75%) so its farthest content point sits inside Android's adaptive-icon safe zone (a circle of ~30.5% of the icon's radius from center) - checked by computing the actual farthest-point distance for both versions, not just eyeballed, since a maskable icon that ignores that zone gets silently clipped by circular-mask launchers. index.html-<link rel="manifest">, atheme-colormeta (Chrome/Android chrome), and the separate iOS-only tags Safari needs instead of reading the manifest for the equivalent behavior (apple-touch-iconlink,apple-mobile-web-app-capable,apple-mobile-web-app-status-bar-style,apple-mobile-web-app-title).
build.mjs gained one cpSync(src/public, dist, { recursive: true })
call alongside its existing index.html copy, rather than naming the
manifest and every icon individually - adding another icon later needs
no build.mjs change. express.static (already serving all of
client/dist unconditionally) picks up manifest.webmanifest and
icons/* with no server-side change at all.
Verified: full typecheck/build (client only). Confirmed directly
against a real running instance of this server, not just assumed:
manifest.webmanifest and all three manifest icons resolve 200 with
the correct application/manifest+json/image/png content types
(express's static middleware infers both from the file extension
correctly on its own), the apple-touch-icon link resolves 200, the
generated PNGs are genuinely the declared dimensions (32/180/192/512),
and the page's own console shows no manifest-related warnings after
loading it. Not verified: an actual "Add to Home Screen"/install
prompt, or the installed app's standalone window, on a real device -
no device reachable from this sandbox, same standing gap as every
other browser-native integration in this project (casting, PiP, the
intro-skip buttons).
Mobile navigation: a bottom sheet, not a reshaped sidebar
Asked for directly: the sidebar's old mobile treatment just reshaped itself into a horizontally-scrolling top bar under 720px (a single media query in Shell.css) - functional, but easy to miss (nothing signals there's more to scroll to) and it buried "Se déconnecter" at the far end of that scroll. Explored as a set of mockups first (four patterns compared side by side, including the tradeoffs of each) before writing any real code - the bottom sheet won out over a bottom tab bar (too few slots for an unbounded number of Emby libraries), a hamburger drawer (still the smallest code delta, but two taps for "Accueil" too), and a merely-polished version of the existing horizontal bar.
Structure mirrors the real data shape, not a flat list: getLibraries()
already returns each library's Emby CollectionType as collectionType
on LibraryDto (server/src/services/CatalogService.ts) - confirmed by
reading that service directly rather than assumed. The sheet uses it to
render three zones instead of one flat list:
- Accueil - one fixed row, always first.
- Bibliothèques - a section label over a 2-column grid of every
library except the one whose
collectionTypeis'playlists'- this is the part whose count and contents genuinely depend on what the connected Emby server has (2 libraries or 10, movies-only or every type at once). - Listes de lecture - pulled out of that grid into its own fixed
row, same visual weight as Accueil, bookending the group. Only
rendered at all when a
'playlists'-type library is actually present - same conditional-presence rule the sidebar already applies to every library type, just applied one level deeper here.
Zero server-side work for this split - collectionType was already
being sent, just never read on the client before now.
One createLibraryLink() builds both trees. The desktop sidebar's
existing plain-text links and the mobile sheet's icon+text links are
different enough (icon or not, grid cell or plain row) that unifying
them into one shared DOM subtree styled two ways would need more CSS
trickery than it saves - so Shell.ts keeps two small render call
sites, both reading the one getLibraries() result, sharing the one
link-building function. The two logout buttons (sidebar footer, sheet
footer) are separate DOM nodes for the same reason - same label, same
click handler, wired once via .logoutBtn rather than #logout.
Icons are new - the sidebar has never had any. Small inline SVGs,
same "constant string, currentColor" convention views/player/Player.ts
already uses for its own icons, one per Emby CollectionType
(movies/tvshows/music/musicvideos/playlists) plus a generic
fallback for any future type this app hasn't mapped yet.
The music bar's stacking, made structural instead of a magic number.
MusicBar.css's .musicBar no longer positions itself - a new
.bottomStack (position: fixed; bottom: 0, plain flex column) wraps
it together with the new mobile nav trigger button, so the trigger
always sits flush above the bar's actual rendered height. That
height isn't constant - .musicBarRow wraps onto a second line on a
narrow screen with a long track/artist name plus presence avatars -
so a hardcoded pixel offset would have drifted; normal flow inside one
shared fixed wrapper can't. Transparent to desktop: the trigger is
display: none there, so .bottomStack just becomes a plain wrapper
around the bar, unchanged from before.
The sheet itself (.mobileNavSheet) and its scrim are independent
position: fixed overlays above .bottomStack's z-index, sliding up
via transform: translateY - closes on: tapping the scrim, tapping any
of the three zones' links (one delegated click listener, not one per
link), or any hashchange (covers browser back/forward too, alongside
the existing highlight() listener that already ran on that event for
the active-link state - now widened from #sidebarNav .navItem to
every .navItem on the page, so both trees highlight consistently).
Verified: full typecheck/build (client only). The interaction logic
- open/close, closing on a link tap, active-state highlighting, the
grouped Accueil/Bibliothèques/Listes-de-lecture split, the conditional
playlists row - was proven first in the mockup artifact (identical
logic), then re-verified here directly against the real shipped
Shell.ts/Shell.html/compiledShell.css, wired up with fake library data (no Emby login reachable from this sandbox) since the real Shell only ever mounts after a real login: the desktop tree still renders correctly untouched at desktop width, the compiled CSS's media query and base mobile rules match what was written byte-for-byte, and the mobile sheet's open/close/navigate/logout interactions were driven programmatically end to end (element.click()still fires listeners on adisplay: noneelement, so this didn't need a real narrow viewport to exercise the logic itself). The music-bar/trigger stacking was confirmed visually with a synthetic long-track-title music bar sitting flush above the trigger, no gap or overlap. Not verified: the actual rendered layout at a genuinely narrow viewport - this sandbox's browser tool's viewport-emulation stopped taking effect partway through this session (screen.widthreflected the requested size,innerWidthandmatchMediadid not - a tooling issue hit repeatedly this session, not something in this app's code), so the mobile-width visual check above was done by forcing the same CSS rules through an injected override instead of a real ≤720px viewport. Worth an early real-phone check once deployed.
Music UI rework: circles and squares instead of movie posters, a real now-playing screen
Asked for directly, "inspired by other apps": the music side of this
app reused tiles.ts's poster tile (2:3, hover-revealed actions) for
artists and albums too, since it was already there for movies/shows -
functional, but a 2:3 box stretches an artist photo or a square album
cover oddly, and doesn't read as "this is music" at a glance the way a
circle/a square immediately does. Explored as mockups first (an
interactive artifact covering all four screens, mobile and desktop)
before writing any real code.
Nothing invented that doesn't have a real data source behind it - no "popular tracks" (Emby has no listening-stats endpoint this app uses), no "previous track" (there's no playback history, just the shared queue), no favorites (no such concept server-side). Every control below does exactly what its equivalent already did.
tiles.ts-tileHtml()now dispatches on the item's Emby type:MusicArtistgets a newartistTileHtml()(a circle, no hover overlay - there's only ever the one action, open the artist page, so the whole tile is just that link) andMusicAlbumgetsalbumTileHtml()(a square, same "whole tile is the link" reasoning). Dispatching inside the one shared function - not adding a renderer parameter every call site has to pass - is what keeps the home carousels, the library grid, and the artist page's own album grid all rendering identically with zero changes to any of those three call sites.views/artist- a real hero: circular photo, name, album count, and a pill-shaped play button (a circular accent-colored icon badge- the existing text label, not icon-only - see the note below on
why). New
Artist.css(this page had none before).
- the existing text label, not icon-only - see the note below on
why). New
views/album- the existing header already had the right shape (a square cover, a flex row) - it keeps its own class names, just gains the same pill play button and aheroKicker("Album · 2023") pulled out of the title instead of appended inline. Each track row's wide text button ("Écouter ensemble"/"Ajouter à la file") becomes a small circular icon (play, or a plus once a party's already active - the same two states, just an icon swap instead of a label swap) - with a whole album's worth of rows stacked, one word-length pill per row read as noisier than the plain icon every real track list uses.- Explicit labels stay, deliberately, on the two hero play buttons
(
heroPlayBtn/heroPlayIcon, shared between the artist and album pages) - a circular icon badge sits inside the button next to real text, rather than the button being icon-only. This app already standardized on icon-only for pure transport controls (Player.ts's own play/pause/mute/fullscreen/cast) but explicit text for anything request- or action-shaped (see the player-rework section above) - starting or queueing a whole discography/album reads as the latter, so the label stayed. The per-track buttons above are the one deliberate exception - not because the convention changed, but because a whole album's worth of them repeats the exact same action as the hero button one row above, where the icon alone is unambiguous in context. musicBar/MusicBar- the biggest change, restructured without touching any of its existing sync/cast/HLS logic:#playPauseBtn/#voteSkipBtn/#toggleQueueBtn/#leaveBtnall move from.linkliketext ("Passer (vote) - 2/3","File d'attente","Quitter") to icon buttons, matching#castBtn's own pre-existing icon treatment instead of standing out as the only text links in the bar. The vote tally becomes a small badge (#voteBadge) on the skip icon instead of appended text.- New
#mobileMiniBar- a collapsed preview (art, name/artist, a big play button, a thin passive progress line) that'sdisplay: noneon desktop, where#musicBarPanelbelow is already always visible - nothing to collapse into a preview of there. #musicBarPanelnow wraps everything that used to be.musicBarRow- on desktop it's the same always-visible bar, restyled as a 3-column grid (track info / centered transport / presence+cast+leave) instead of one wrapping flex row. On mobile it becomes a full-screen "now playing" overlay (position: fixed; inset: 0, sliding up viatransform), opened by tapping#mobileMiniBarand closed via a new#panelCloseBtn- the actual mobile screen this app was missing entirely, not just a resized version of the bar.#trackProgress(the existing thin top-edge seek strip) didn't move or change shape at all - it was already the "thin bar along the top" treatment asked for, just needed to not be reinvented as a different, boxed, labeled scrub bar for desktop specifically (an earlier mockup draft did exactly that before this same feedback landed and got fixed there first).MusicBar.css's.musicBarno longer positions itself at all - unchanged from the mobile-nav work above,.bottomStackowns theposition: fixedwrapping so#mobileMiniBaralways sits flush above whatever height the (now-hidden-behind-it-when-collapsed) panel would otherwise occupy.
A real bug caught by verifying computed styles, not just reading the
CSS: .musicBarPlayBig (the big circular play button, shared between
#playPauseBtn and #miniPlayPauseBtn) was silently losing its own
background/border-radius to .iconBtn's (Player.css) at equal
selector specificity - which one wins depends on which file esbuild
happens to bundle later, not on anything either file's author controls.
Fixed by qualifying the selector as .iconBtn.musicBarPlayBig (both
classes are always present together on these two buttons), whose
higher specificity wins regardless of bundle order. Caught by checking
getComputedStyle in a real browser against the real compiled
app.css, not by re-reading the source and assuming the declared rule
wins - it visibly didn't, rendering as a plain 6px-radius transparent
button instead of a filled circle.
Verified: full typecheck/build (client only). Real compiled
app.css + the real Shell.html/MusicBar.html markup, driven with
fake artist/album/track data (no Emby login reachable from this
sandbox): circular vs square tiles and their exact aspect-ratio/
border-radius confirmed via getComputedStyle, the per-track icon
swap (play vs plus) confirmed per row, and #mobileMiniBar's open/
close/guard-against-tapping-the-play-button behavior all driven
end to end and confirmed via real click events - not just read back
as source. This sandbox's browser tool's own viewport emulation
stayed unreliable for the rest of this session (innerWidth didn't
track a requested resize), so the mobile-width visual checks were
done the same way as the mobile-nav work above: forcing the mobile
media query's declarations through an injected override rather than a
genuinely narrow viewport. Not verified: any of this against a
real Emby server's actual artist/album images and names, or on a real
phone.
Mobile navigation, take two: swipe-to-close was fighting pull-to-refresh
Reported directly, against a real phone: the bottom sheet (see "Mobile
navigation" above) had exactly one way to close it - tap the dimmed
scrim - since the grabber was purely decorative. Swiping the sheet
down, the instinctive gesture every real bottom sheet supports, fell
through to the browser's own default touch handling instead: with
nothing telling it otherwise, a downward drag starting on the scrim or
the sheet reads to the browser as "the user wants to pull the page
down," which on a phone is the built-in pull-to-refresh gesture - not
a bug in this app's own logic, but a real gap in it (nothing was
preventDefault-ing or claiming that gesture for the sheet instead).
Two independent parts, not one fix: containing the browser's own gesture, and giving the swipe something to actually do.
touch-action: noneon.mobileNavScrimand the new.sheetGrabberHandle- tells the browser not to apply any of its own default touch behavior (panning, pull-to-refresh) to a touch starting on either, since this app's own pointer handlers own that gesture now.overscroll-behavior: containon.mobileNavSheetitself covers the other half of the same problem: its own library grid can be taller than the sheet's75vhcap, so a drag that hits that scroll boundary needs to stop there too, not chain past the sheet to the page underneath.- The grabber is now a real drag handle, not just a visual hint.
#sheetGrabberHandlewraps the small visible pill in a much bigger invisible touch target (a real touch target, not a 36x4px sliver) and drives the sheet with the same pointerdown/move/up(/cancel) patternviews/player/Player.css's own seek bar already uses: 1:1 tracking under the finger while dragging (.draggingsuspends the CSS transition for this), then either snapping back open (a drag under 90px) or calling the exact samecloseMobileNav()the scrim tap already used (past it) - one close path, two ways to reach it, not a second parallel implementation. Deliberately scoped to the grabber alone, not the whole sheet: the sheet's own content (the library grid, the logout row) needs normal taps and its own scroll to keep working, which a whole-sheet drag-to-close would fight.
Verified: full typecheck/build (client only). The CSS properties
(touch-action/overscroll-behavior) confirmed applied via
getComputedStyle against the real compiled app.css. The drag logic
itself confirmed with dispatched PointerEvents carrying real
clientY/pointerId values against the real handlers copied from
Shell.ts: a 40px drag snaps back (stays open, transform cleared),
a 120px drag closes, and the live transform mid-drag tracks the
pointer 1:1 (confirmed at an intermediate point, not just the start/end
state) - not just asserted from reading the threshold constant.
Existing behavior re-confirmed unchanged in the same pass: scrim-tap-
to-close still closes. Not verified: actual touch input (not a
mouse or dispatched PointerEvent) on a real phone - no device
reachable from this sandbox, same standing gap as this project's other
touch-specific work (the video/music seek bars' own drag handling).
Toast notifications, replacing every blocking alert()
Asked for directly, prompted by the host-handoff message specifically
("L'hôte précédent a quitté la party - tu es maintenant l'hôte.") -
a blocking alert() freezes the whole tab and demands a click to
dismiss, for what's genuinely just a heads-up. Once looking at that
one, every OTHER alert() in the client turned out to be the exact
same pattern (alert((err as Error).message) after a failed
create/join, or a bare onError: (message) => alert(message) on a
socket connection) - ten call sites total, across Player.ts,
MusicBar.ts, tiles.ts's shared wireTileButtons, and every one of
Home/Series/Playlist/Artist/Album's own "start a party"
buttons. All ten now go through one new module instead.
New toast.ts/toast.css - a plain module-level singleton, not
something Shell.ts mounts like musicBar/MusicBar (there's no
per-party state here, just somewhere to drop text), so it lazily
creates its own fixed-position container on first use and works from
literally any file that imports showToast(). Visually the same
light pill/auto-dismiss language views/player/Player.css's own
.requestBanner already used for pause/resume requests, generalized
to position: fixed against the viewport (not .playerWrap
specifically) so it works from any page, stacked (a flex column with
gap, so an error and a moment-later status update don't clobber each
other) and layered above everything else in the app (z-index: 100,
comfortably clear of the highest anything else currently uses).
Two variants, one restrained look - variant: 'info' (default,
the host-handoff message, every status-only case) and variant: 'error' (every migrated alert() above) differ by exactly one thing,
a plain border-left accent in var(--danger). Deliberately not a
red banner/fill even for errors: a failed "start a party" click is
still just a message, not something this app should suddenly shout
about - matching the restraint the rest of this app's UI already
favors (subtle background tints, not loud fills, for e.g.
.avatarActionRequested). No manual dismiss button, same as
.requestBanner before it - every toast fades itself out after
~4.5s, respecting prefers-reduced-motion (an instant show/hide
instead of the opacity/transform fade, same guard .spinner already
uses elsewhere).
Verified: full typecheck/build (client only). Confirmed there are
no alert( calls left in client/src beyond comments that merely
mention the word. The fade-in double-requestAnimationFrame trick
(needed because adding the "visible" class in the same frame as the
insert can get coalesced with it, silently skipping the transition)
was checked directly against a real element in a real browser rather
than assumed from the general pattern.
Library/artist grid: the tile minimum was too wide for a real phone
Reported directly, against a real device: a phone in the
~360-393px-wide range (an ordinary width, not a rare one) was showing
exactly one tile per row in every library/artist grid. The grid's
minmax(160px, 1fr)) floor was never wrong on its own terms - it's
just wider than what's left once .mainContent's own side padding is
subtracted from a real phone's width, and there was no narrower-screen
override for it at all (unlike the sidebar/nav or the music bar,
tiles.css's .grid had never gotten one).
Fixed with minmax(clamp(100px, 28vw, 160px), 1fr) - a fluid minimum,
not a max-width breakpoint. clamp() scales continuously with the
real viewport instead of jumping at one arbitrary width, so every size
in between "phone" and "desktop" (a tablet, a half-width browser
window) already gets a reasonable column count too, not just the two
sizes a breakpoint would have explicitly targeted - and it reduces to
today's exact 160px past whatever width 28vw alone would already
exceed it, so desktop is provably unchanged rather than just "probably
fine."
Verified: full typecheck/build (client only). Confirmed in a real
browser, not just computed by hand: an <iframe> sized to each of
320/360/375/393/430/768/1200px (an iframe gets its own independent
viewport for vw units, which this sandbox's own unreliable
resize_window emulation can't provide - see the mobile-nav/music-UI
sections above) with the real compiled tiles.css loaded inside it,
checking the actual rendered column count at each. Every phone width
from 320 to 393px now gets 2 columns (previously 1), 430px gets 3, and
768/1200px match what the old fixed 160px floor already gave -
confirmed empirically, not assumed from the formula alone.
The library header wasn't fully sticky: the title still scrolled away
"A sticky search bar" (above) only ever made the <input> itself
sticky - asked for directly again, this time to also keep the page
title (.pageHeader's <h1>) pinned alongside it on views/library,
so both stay visible together while a long grid scrolls underneath
instead of just the search box.
New .libraryHeader wrapper (views/library/Library.css - the one
view of this shape that had no dedicated stylesheet yet, everything
borrowed from tiles.css/base.css) around the existing .pageHeader
.searchBarpair,position: sticky; top: 0on the wrapper itself with its own opaquevar(--bg)background - the<h1>has none of its own, so without this, tiles scrolling underneath would show through the title text once stuck..searchBarkeeps its own pre-existingposition: sticky; top: 0fromtiles.cssunchanged (still needed as-is onviews/artist/views/album, which don't get this wrapper); nested inside an already-stuck ancestor that isn't itself scrolling, it's inert rather than conflicting.
Scoped to views/library only, not tiles.css's shared .pageHeader -
views/home uses that same class for a plain, non-sticky title, and
views/artist/views/album have since moved to their own hero headers
(see "Music UI rework" above), not .pageHeader at all.
One real collision worth catching: below 720px, shell/Shell.css's own
.mobileTopBar is also position: sticky; top: 0 (the app's brand
bar, sitting above .mainContent as a sibling, not an ancestor) - the
pre-existing sticky .searchBar already raced it for the same spot,
just narrowly enough (one slim input) not to get reported. Stacking a
whole title+search block at top: 0 too would have properly covered
it, later in paint order. .mobileTopBar got an explicit
height: var(--mobile-topbar-h) (new base.css token, 49px -
replacing its previous padding-derived auto height with a fixed one
anything else can reliably stack against), and .libraryHeader sticks
to top: var(--mobile-topbar-h) instead of 0 under that same 720px
breakpoint - directly below the brand bar instead of underneath it.
Verified: full typecheck/build (client only). No Emby session is
needed to see either state, so checked directly: real compiled
app.css loaded (same-origin, from the running dev server) into two
<iframe>s - 1000px and 375px wide, each its own independent viewport
for the 720px media query, the same workaround this sandbox's own
broken resize_window emulation has needed all session - reproducing
Shell.html's .mobileTopBar/.mainContent plus Library.html's new
markup and a couple dozen dummy tiles, then scrolled deep into the grid
in both and read getBoundingClientRect() back: on the 1000px frame
the title and search bar both stayed at the viewport top, tiles
scrolling underneath, opaque background confirmed; on the 375px frame
.libraryHeader's top edge landed exactly at .mobileTopBar's bottom
edge (49px) with zero overlap, both bars' text still visible together.
Alphabet index: jump straight to a letter in a library
Asked for directly, alongside the sticky header above: a way to jump to a letter instead of scrolling a long library by hand. Proposed two mockups first (a right-edge A-Z rail either bare or paired with sticky per-letter section headings, both with the drag-to-scrub-plus-big-letter- bubble feel of iOS's own Contacts index) - the section-headings version was picked.
New alphaIndex.ts, deliberately not folded into tiles.ts: it's a
views/library-only concern (the ask was specifically "dans les
bibliothèques"), and tiles.ts stays exactly what every other page
still wants - one flat, ungrouped grid.
Grouping buckets each item by its first letter, accents folded to
their base (normalize('NFD') + stripping the combining marks) so
"Écho" lands under "E" like a French speaker would expect, not off in
its own accent-specific bucket. No client-side re-sort needed -
embyApi's getLibraryItems/getArtists both already ask Emby for
SortBy: SortName, so a run of same-bucket items is already contiguous.
Layout: a .letterHeading (grid-column: 1 / -1) inserted before
each new bucket, directly inside the same .grid tiles.ts's own tiles
already populate - not a separate mini-grid per letter, which would have
needed its own column-count logic duplicated 20+ times over. Its top
(both for its own position: sticky and as every jump target's
scroll-margin-top, so a jump doesn't land a heading half-hidden behind
the still-stuck header above it) is measured off .libraryHeader at
runtime - getComputedStyle(libraryHeader).top picks up whichever of
Library.css's two breakpoint values (0 desktop, --mobile-topbar-h
mobile - see the sticky-header section above) is currently active
without this module needing to know that breakpoint exists at all.
renderGroupedTilesInChunks mirrors renderTilesInChunks's own chunked
insertAdjacentHTML (see that function's comment - a real library can
run past a thousand items, and one synchronous innerHTML write for all
of them is exactly the "front freezes while it loads" failure this app
already fixed once elsewhere); a chunk boundary landing mid-bucket is
harmless, headings and tiles just interleave in whatever order they're
inserted.
The rail (.alphaRail, fixed to the right edge) is 27 buckets
(# + A-Z), each dimmed and inert if items has nothing in it. Tap or
drag it - pointerdown/move/up on one dedicated element with
touch-action: none and setPointerCapture, the identical shape
shell/Shell.ts's own nav-sheet drag handle already uses - and a
center-screen bubble shows the current letter while dragging, the same
"big transient label during a drag" idea as that handle's own gesture,
applied here to a very different one. Scrolling normally (not
dragging) instead runs a small scroll-spy on window's scroll event,
highlighting whichever bucket's heading is currently at the top.
Both the rail and every heading's sticky offset track two elements'
real rendered size rather than a guessed constant - a ResizeObserver
on .libraryHeader (a very long title could wrap to two lines) and on
shell/Shell.html's .bottomStack (the music bar floats there whenever
a listen party is active, on desktop as well as mobile, and can
start/stop while this page stays open) - so the rail's own bottom
always stops exactly above whichever of those is currently showing,
instead of a listen party's music bar quietly covering "X"-"Z".
Search still uses tiles.ts's existing wireSearchFilter unchanged
(.tile-level show/hide) - wireSectionSearchSync just walks each
.letterHeading's following siblings up to the next one and hides it
too if every tile in that run is now .hide'd, registered on the same
input after wireSearchFilter so it always sees the up-to-date
.hide state (same-element listener order = registration order).
Without it, searching down to matches in one bucket would leave every
other bucket's heading floating above nothing.
Cleanup: renderLibrary itself had to change shape - from a plain
async function to the synchronous-wrapper-around-an-async-IIFE
views/player/Player.ts's renderPartyPlayer already uses, returning
a real CleanupFn (see router.ts) instead of the Promise<void> an
async function can't help but return. Both the rail's two
ResizeObservers and its window scroll/resize listeners live past
whatever DOM router.ts's next innerHTML = '' clears, so without an
explicit teardown they'd keep running (and, for the scroll listener,
keep doing real work) after leaving the page - the exact leak class
"Realtime transport" up above already covers this app getting bitten by
once.
Verified: full typecheck/build (client only). No Emby session
available here, so the interaction itself was checked by bundling
alphaIndex.ts alone (a throwaway esbuild entry point, deleted after)
into a synthetic page reproducing the real Shell/Library DOM and real
compiled CSS, then driven for real rather than assumed: a genuine
browser click on a rail letter (not a script-dispatched one - dispatched
PointerEvents never register as an active pointer, so setPointerCapture
itself only ever proved out under real input) jumped and highlighted
correctly with zero console errors; dragging across several letters
updated the active one and the bubble live at each step; tapping an
empty letter highlighted it but left scroll position untouched; typing
into the search box hid exactly the buckets left with no visible tiles
and none of the others; growing #musicBar's height live moved the
rail's bottom edge with it and back; and calling the returned cleanup
stopped that last reaction cold, confirming the observer was actually
disconnected rather than just idle.
The alphabet index's headers were in the wrong order
Reported directly, against a real library: header letters coming out
as e.g. A, B, T, B, L instead of one run per letter in order. Not a
sorting bug in Emby, and not a bug in renderGroupedTilesInChunks's own
"new bucket = new heading" logic either - both were already correct.
The actual mismatch: getLibraryItems/getArtists ask Emby to order
the array by SortBy: SortName, but never asked for each item's
SortName value back, so alphaIndex.ts's bucketFor had nothing to
bucket by except the raw display name - and Emby's SortName strips
leading articles ("The", "Le", "L'", ...) for exactly the kind of
natural alphabetical browsing this index is for. An item like "The
Bunker" sorts (correctly) among the B's by Emby's own SortName, but
bucketing it by its literal display name instead computed "T" - a
spurious heading mid-run, immediately followed by a second, duplicate
"B" heading once the next real B-titled item came right back around.
Every other letter, with no such item, looked completely fine, which is
why this wasn't caught by the earlier synthetic verification (its fake
titles never happened to include an article-stripped one at all).
Fixed at the source instead of trying to reimplement Emby's own
article-stripping rules client-side (which language's articles, and
which are configurable server-side, isn't this app's business to know):
getLibraryItems/getArtists now also request Fields: SortName, new
ItemDto.sortName carries it through (falling back to name when a
caller doesn't request it - same "populated where it matters, harmless
default elsewhere" shape durationTicks/indexNumber already use), and
alphaIndex.ts buckets by that, not name. Bucketing by the exact
same key Emby used to order the array is what guarantees a bucket is
always one contiguous run - not bucketing by something that's usually
close enough.
Verified: full typecheck/test/build on both projects (147/147
server tests). Reproduced the exact reported shape first - a small
fake item list in Emby's own SortName order, one entry's display name
starting with a different letter than its sortName ("The Bunker",
sortName: "Bunker", positioned between two other B titles) - confirmed
it produced the same A, B, T, B, L heading sequence against the old
bucket-by-name code, then confirmed the fix collapses it to the
correct A, B, L against the exact same input.
The music bar's queue popup: broken on desktop, unclosable on mobile
Two reports that turned out to be the same missing position rule.
.musicBarQueue's own comment already said it should "pop up ABOVE the
bar" (like Player.css's .settingsPanel above its gear button), but
the CSS itself never actually gave it a position on desktop at all -
a plain normal-flow block, first child of .musicBar. Toggling it
visible didn't float anything; it pushed #musicBarPanel (the actual
transport controls) out of the way, growing .bottomStack's own
fixed-to-viewport box from the bottom up - reported directly as the
player looking broken the moment the queue was opened.
Mobile had a different-looking but related bug. #musicBarPanel
becomes a full-screen position: fixed overlay under 720px; a normal-
flow .musicBarQueue sibling would have rendered behind it, so it
had already been given position: relative; z-index: 26 specifically
to still show on top once opened from inside that full-screen panel
(see the git history for that rule's own reasoning). That's a real fix
for visibility, but not for layout: position: relative leaves an
element sized and placed by normal flow, which put the queue somewhere
in the middle of the screen rather than covering it - directly
overlapping the very #toggleQueueBtn used to open it, the only way
that existed to close it again. Reported directly as "can't be closed."
Fixed with a position on both sides, matching each context's actual
shape - .musicBar gets position: relative (a containing block),
desktop's .musicBarQueue becomes position: absolute; left: 0; right: 0; bottom: 100% (floats directly above the bar, out of normal
flow, without touching #musicBarPanel's own layout at all), and
mobile's becomes position: fixed; inset: 0 (a proper full-screen
overlay of its own, max-height: none overriding the base rule's
50vh so it isn't clipped) - the same "size for what this context
actually needs" as #musicBarPanel's own fixed/inset:0 treatment right
next to it.
Also added, so closing it never again depends on precisely where the
toggle button happens to render underneath: a dedicated #queueCloseBtn
inside the queue's own header, plus a click-outside-closes listener on
document (views/player/Player.ts's own settingsPanel pattern) for
the desktop popover case.
Verified: full typecheck/build (client only). No Emby session here,
so checked directly - initMusicBar called against a synthetic
.bottomStack/#musicBar reproducing the real Shell markup, real
compiled CSS, a fake queue list: on the (real, 1280px) desktop viewport,
opening the queue landed its bottom edge exactly on the bar's own top
edge (zero gap, zero overlap) without moving the bar at all, and both
the close button and a click on unrelated page content behind it closed
it correctly. On a 375px-wide <iframe> (this sandbox's own
resize_window viewport emulation is unreliable - see the mobile-nav/
music-UI/library-grid sections above), with the now-playing panel
opened first: the queue rendered as a genuine full-viewport overlay,
elementFromPoint at the close button's own center hit the close button
itself (not something on top of it), and closing the queue correctly
returned to the still-open now-playing panel rather than closing
everything.
Library tile buttons rendering on top of the sticky header
Reported directly: scrolling a library, a tile's own action buttons ("Lancer une watch party" / "Voir sur Emby") would briefly render above the sticky title+search header instead of scrolling behind it.
Both .libraryHeader and the alphabet index's .letterHeading were
z-index: 2 - copied from tiles.css's own pre-existing .searchBar,
sticky at that same value long before either of them existed. What
that copy missed: a tile's own .tileOverlay (its hover-revealed
action buttons - always-visible rather than hover-revealed on a touch
device, see tiles.css's (hover: none) block) is also z-index: 2.
Equal z-index is a tie, and a tie breaks toward whichever renders later
in the DOM - a tile, inside #grid, always after the header. The
original slim .searchBar never showed this in practice (a single
input has little vertical range to actually overlap a scrolling tile
during the transition); wrapping it into the much taller
.libraryHeader gave a scrolling tile far more room to pass directly
underneath before fully clearing it, is what actually surfaced it.
Fixed by raising .libraryHeader and .letterHeading to z-index: 3
- comfortably clear of
.tileOverlay's 2, and matchingshell/Shell.css's own.mobileTopBar(the other sticky header.libraryHeaderalready has to stack correctly under on mobile).
Verified: full typecheck/build (client only). Confirmed as a real
regression, not just reasoned about - real compiled CSS loaded into a
page reproducing .libraryHeader + a real .grid of tiles (their
.tileOverlay forced visible, simulating a touch device), scrolled so
a tile genuinely overlapped the header's own rect, then
elementsFromPoint read the actual paint order at that point: with
the header's z-index forced back to the old 2, .tileOverlay topped
the stack exactly as reported; restored to 3, the header (and the
search input inside it) topped it instead, .tileOverlay three layers
down, unreachable.
The music bar's compact layout: the progress bar was never actually full-width
Reported directly, again, after the queue-popup fix above - the desktop bar still didn't look right: the progress bar needed to span the full width, with everything else in one row underneath.
.musicBarProgressTrack's own comment already described it as "a thin
strip along the bar's own top edge" - the intent was always a full-width
strip above a row of controls, mirroring Spotify/YouTube Music's own
compact players. But #trackProgress (that element) is .musicBarPanel's
first child inside a 3-column grid (.musicBarPanelTrack/
.musicBarPanelTransport/.musicBarPanelSide), with no grid-column
of its own - so grid auto-placement dropped it into column 1 of row 1
like any other item, squeezing it to that column's own width and
wrapping .musicBarPanelSide onto a second row underneath it instead.
Easy to miss at a glance: with no track loaded, .musicBarPanelTrack's
own content (a small image + short placeholder text) still left column
1 wide enough that the squeeze didn't look obviously broken - see the
queue-popup section above for the other half of "cassé en desktop",
found and fixed first without this one.
Fixed with one line: .musicBarProgressTrack { grid-column: 1 / -1; }.
Spanning all 3 columns in row 1 is what pushes every sibling after it
(auto-placed, no changes needed to any of them) into row 2, across the
full width - track/transport/side exactly where the 3-column rule
already intended them, just no longer sharing row 1 with the progress
bar.
Verified: full typecheck/build (client only). Checked directly - a
synthetic page with real compiled CSS, initMusicBar called against a
reproduction of the real Shell markup, a fake track's name/artist/
position set so .musicBarPanelTrack has realistic content width. Read
back with getBoundingClientRect(): the progress bar's own width
matched .musicBarPanel's full content width exactly (not the viewport,
which include its own outer padding); its bottom edge sat at or above
every other row's top edge; and the other three elements' vertical
ranges genuinely overlapped each other (not just similar top values,
which align-items: center legitimately varies by each one's own
content height) - confirming one shared row below, in the original
track/transport/side left-to-right order.
An album's track rows collapsed to 3 lines on mobile for no reason
Reported directly: there was room to fit an album's track rows
(index/name/duration/a small play-or-queue button) on one line on
mobile, but trackList.css's own @media (max-width: 600px) forced
.trackRow down to a 2-column grid and pinned both .trackDuration
and the button to that same second column - auto-placement then
stacked the two of them into their own rows underneath the track name,
three rows total instead of one.
Removed outright rather than tuned - the numbers don't support it
having ever been needed. .trackRow's fixed overhead (28px index +
2×12px gaps + auto-sized duration/button + 12px horizontal padding)
comes to roughly 155-165px, and even the narrowest common phone width
(320px) leaves comfortably more than that for .mainContent's own
side padding plus a 1fr name column - .trackName's existing
text-overflow: ellipsis was already doing exactly the job this
breakpoint assumed it couldn't.
Verified: full typecheck/build (client only). Checked directly, not
assumed: real compiled CSS, a handful of .trackRows (short and
deliberately overlong names) loaded into two <iframe>s at 320px and
375px - this sandbox's own resize_window viewport emulation is
unreliable, see the mobile-nav/music-UI sections above. At both widths
every row's index/name/duration/button sat on one line (their vertical
ranges genuinely overlapping, not just similar by coincidence), left to
right in that order, with the long names confirmed actually truncated
(scrollWidth > clientWidth) rather than merely happening to fit.
A mobile install nudge for the PWA
Asked for directly: the PWA (see "Progressive Web App" above) existed, but nothing ever told a mobile visitor it could be installed at all - Chrome's own default mini-infobar is easy to miss/dismiss, and Safari has no browser-driven prompt whatsoever.
New pwaInstall.ts, self-mounting like toast.ts (no per-route/
per-party state to attach) but with one real difference from that
module's own lazy-init pattern: beforeinstallprompt (Chrome/Edge/
Android WebView's event for this - not in lib.dom.d.ts, Safari/
Firefox never implement it) can fire before any UI exists to react to
it, and the browser never replays it for a listener that attached too
late. The listener itself is a top-level side effect of importing the
module - shell/Shell.ts does that (and calls the module's own
initPwaInstallPrompt(), after initMusicBar), and since ES module
imports all evaluate before an entry file's own top-level code runs,
that listener is live well before app.ts's boot() even starts its
async login check, regardless of how soon the event actually fires.
Two real install paths exist, and the banner speaks to whichever one applies:
- Chrome/Edge/Android WebView:
event.preventDefault()stashes the captured event (this banner replaces the browser's own default one); clicking "Installer" calls the realprompt()and reads backuserChoice- only an outright 'accepted' removes the banner, since the picker's own 'dismissed' just means "not right now", not "stop asking" (that's what the banner's own close button is for). - iOS Safari: no such event exists at all, ever - detected instead by user-agent sniffing (there's no clean feature-detection for "is this iOS Safari"), showing manual instructions ("appuie sur Partager puis Sur l'écran d'accueil") with no button, since there's nothing to programmatically trigger.
Mobile-only (matchMedia('(max-width: 720px)'), this app's own
existing breakpoint, re-checked on resize for a live rotate/unfold) and
suppressed entirely once already running installed
(display-mode: standalone, or navigator.standalone for iOS Safari's
own equivalent flag, which has no display-mode query at all). A
close click remembers "don't ask again" in localStorage - not
re-shown even against a fresh beforeinstallprompt on a later visit.
Rendered as a plain normal-flow banner at the very top of <body>,
before #app - not a fixed/sticky overlay - specifically to avoid
needing to coordinate yet another offset with shell/Shell.css's own
already-stacked .mobileTopBar/.libraryHeader pair (see the sticky-
header sections above) or alphaIndex.css's rail. It simply pushes the
app down by its own height and scrolls away with the page, the same as
a browser's own native install infobar would.
Verified: full typecheck/build (client only). No real device
install flow is triggerable in this sandbox (nothing can fabricate a
genuine browser installability signal), so checked everything around
that boundary directly instead: a synthetic beforeinstallprompt-
shaped event (real Event, stubbed prompt/userChoice) dispatched
into real compiled CSS/JS confirmed the banner appears with the
install button, that button calls the stub prompt() and removes the
banner only on an 'accepted' outcome, and that the close button both
removes it and blocks a second dispatched event from reopening it.
Overriding navigator.userAgent to an iPhone string confirmed the
iOS-flavored copy with no install button. A same-event dispatch into a
real ≥900px-wide <iframe> confirmed the banner never renders there
at all, regardless of the event firing.
OS-level media controls (Windows' SMTC, macOS' Now Playing, ...)
Asked for directly: linking whatever's playing to Windows' own media
popup (the one media keys/the volume flyout show) - the standard
Media Session API (navigator.mediaSession), which the same code path
also surfaces to macOS' Now Playing widget and Android's notification
for free, on whichever OS/browser combination actually implements it
(Chromium family; Firefox has never implemented it at all).
New mediaSession.ts, a thin wrapper - every export is a one-line
'mediaSession' in navigator guard around the exact browser call, not
a new abstraction of its own - shared by views/player/Player.ts
(video) and musicBar/MusicBar.ts (a listen party), which can never
both be active at once (starting/joining either already leaves the
other first - see docs above), so there's no risk of the two fighting
over this one global.
Metadata/position/playback state hook into each player's own
existing single-source-of-truth functions rather than adding parallel
call sites everywhere: Player.ts's updatePlayPauseBtn/
updateProgressBar (already the one place #playPauseBtn's icon and
the seek bar's fill/label get synced, from every driver-changed event,
the heartbeat, and the ticker alike) also call
setMediaSessionPlaybackState/setMediaSessionPositionState now;
MusicBar.ts's setPlayIcon/updatePositionDisplay do the same. Title/
artwork update at both places an item/track actually changes
(Player.ts's init/onItemChanged, MusicBar.ts's loadTrack) -
artist/album are left empty for video (meaningless there, same as this
app's own UI already treats them).
Play/pause/seek are wired once per party (not re-wired on state
changes) via setMediaSessionActionHandlers - the handler functions
themselves close over local state (isHost, conn, ...) and read it
fresh on every OS-triggered call, the same way any other closure in
either file already does, so a handler set at party-join time keeps
working correctly straight through a live host handoff without needing
to know that happened. Two real permission models, unchanged by this:
video's play/pause is host-only - a non-host's OS-level play/pause
routes to the exact same requestPause/requestResume (now factored
out so both the in-page buttons and this can call them) the request
toolbar already uses, never touching the video directly - and seeking
is gated on isHost the same way the in-page seek bar already is
(factored into a shared seekTo, reused by both the pointer handler
and this). Music has neither gate (no host concept at all - see
MusicQueueService's own reasoning), so its handlers act directly.
"Next" is handled differently from play/pause/seek because a
registered nexttrack handler is what makes the OS show that
control at all, regardless of whether clicking it would currently do
anything - so unlike the other three, its presence has to track
whatever the in-page equivalent's own visibility already tracks, via a
separate setMediaSessionNextAction(fn | null) called alongside it:
Player.ts's #nextBtn (isHost && hasNextQueued, in becomeHost and
onItemChanged) and MusicBar.ts's vote-skip button (music.current,
in applyMusicState - clicking the OS's own "next" casts one skip
vote, same as the in-page button, not an instant skip).
Clearing: clearMediaSession() (metadata, playback state, and
every action handler) runs on Player.ts's route cleanup and
MusicBar.ts's leaveMusicParty - an OS-level "now playing" card that
outlives the actual party would be actively wrong, not stale. A
lighter setMediaSessionIdle() (metadata/state only, action handlers
left alone) covers MusicBar.ts's own "queue ran empty but still in the
party" case instead - play/pause/seek stay meaningful the instant
something plays again, so there's nothing to re-wire there.
Verified: full typecheck/build (client only). mediaSession.ts
itself checked directly against the real API in a real Chromium
browser (this sandbox's own): metadata resolves to a genuine
MediaMetadata with the right fields and a resolved artwork URL (or
none for an empty one), playback state and position state both apply
without throwing, an invalid (non-positive/NaN) duration is silently
skipped rather than reaching the browser call that would throw on it,
action-handler registration doesn't throw with or without onSeek
supplied, and both setMediaSessionIdle/clearMediaSession reset
metadata to null and playback state to 'none'. Player.ts's/
MusicBar.ts's own wiring is one-line calls into already-verified
functions at points each file already treats as its single source of
truth for that piece of state (see above) - not independently
exercised end to end against a live party (would need mocking the
entire socket/party layer for no more real signal than reading the
call sites already gives), and the actual OS-level popup itself - the
whole point - isn't something this sandbox can display or click
regardless. Worth a real Windows/Chrome check once deployed.
Device takeover: both devices "on the session", neither one's controls working
Reported directly: switching a watch/listen party from one device to
another left both devices looking active, with no controls actually
transferring. The device-takeover mechanism itself - SocketGateway's
"join" handler force-disconnecting the old device's socket once the new
one confirms - was already correct and already guarded against the
obvious failure mode (its own comment on the "leave"/"disconnect"
handlers: a stale connection's own eventual disconnect must not be
mistaken for a real leave). The actual bug was two gaps around that,
neither in the takeover mechanism itself.
Gap 1 - the old device never reacted to being kicked. Both
Player.ts and MusicBar.ts wired onClose: () => {} - a complete
no-op - to the connection's "disconnect" event. So the moment a takeover
force-disconnected the old device's socket, that device's own video/
hls.js instance (or the persistent music bar) just kept running exactly
as before: the socket was dead, but nothing told the local <video>/
<audio> to stop, and nothing navigated away or reset isHost. From
the old device's own screen, everything still looked (and for local-only
playback, sounded) fully live - exactly "both devices on the session."
Fixed: both now react to an unprompted close (one that didn't already
run this route/module's own explicit teardown - leftIntentionally in
Player.ts, activePartyId already being null in MusicBar.ts, both set
synchronously by that explicit teardown well before the async
"disconnect" this checks against ever arrives) by tearing down local
playback and leaving, the same as if the user had clicked "Quitter"
there themselves.
Gap 2 - reacting to the kick had no safe way to do that. The obvious
fix for gap 1 - call the same leave path "Quitter" already uses - ran
straight into a second, worse bug: POST /api/parties/:partyId/leave
removes a member by userId alone, with no equivalent of the socket
handlers' own staleness guard (there's no live connection for a REST
call to compare against). Calling it from the old, superseded device
would have ended the new device's membership too - silently undoing
the takeover it was supposed to be cleaning up after. Fixed by giving
this route the same guard via deviceId instead of a socket reference:
new PartyService.isStaleDevice(member, deviceId), and leaveParty()
(client) now sends its own getDeviceId() for the server to compare.
Only ever blocks on positive evidence of a mismatch - a member with no
recorded deviceId yet, or a caller that sent none, still leaves exactly
as before this existed.
Verified: full typecheck/test/build on both projects (153/153
server tests - new coverage for isStaleDevice covering a matching
deviceId, a mismatched one, an unset member deviceId, an unset caller
deviceId, and an unknown member, each checked against the reason it
should or shouldn't block, not just the outcome). The client-side
onClose fix was checked against the exact reported scenario, not just
reasoned about: a fake io() standing in for Socket.IO (real .on/
.emit/a scriptable .disconnect() that fires real listeners) let
joinMusicParty/leaveMusicParty run for real. Firing disconnect
right after a real leaveMusicParty() call produced no toast and left
the bar hidden (no spurious "connection lost" message on an intentional
leave); firing it with no prior leave - the takeover-kick shape -
produced the "Connexion à la party interrompue" toast and correctly
tore the bar down. Player.ts's own leftIntentionally fix mirrors this
exact shape (confirmed by reading, not re-verified with a second live
harness - hls.js/a full player DOM would need mocking for no more
signal than the MusicBar case already gives, given both share the same
logic shape end to end).
A track started directly never fell back to its album's artwork
Reported directly: the app struggles to find artist/album/track images.
Traced one concrete, confirmed cause: imageUrlFor's own
Audio -> AlbumId fallback (added earlier specifically because a track
essentially never has its own Primary image, only its album usually
does) was silently never triggering for a track played directly - not
via its album or artist - because the raw item that decision runs
against, CatalogService.getRawItem, never requested AlbumId from
Emby in the first place. Emby's default field set for a single-item GET
omits it entirely, so item.AlbumId read undefined there regardless
of whether the track's actual album genuinely had one - imageUrlFor
fell through to the track's own id every time, for every party started
this way.
Every OTHER path that resolves a track's image already asks for it
correctly - getAlbumTracks/getPlaylistItemsRaw both already include
AlbumId in their own Fields, which is why launching a whole album/
artist/playlist, or a queue built from one, was never affected. Fixed
the one gap instead of guessing at a broader rewrite: getRawItem now
requests Fields=AlbumId too. Harmless for every non-Audio item it also
fetches (Movie/Series/Episode/...) - Emby just omits a field that
genuinely doesn't apply to them.
Not the whole story, most likely - a real Emby library can also legitimately have no Primary image at all for a given artist or album (nothing for this app to fall back to, no bug to fix), and this fix only ever covers the one specific path (a track started directly) that was provably missing a field it needed. Flagged directly rather than overclaiming "images are fixed" - worth another, more specific report (which item exactly, from where) if artist/album art still struggles after this.
Verified: full typecheck/test/build (server only - CatalogService.ts
is server-side). New test confirms getRawItem's actual outgoing
request includes Fields=AlbumId (a mocked EmbyClient.fetch capturing
the real path/query it was called with, not just asserting on the
already-separately-tested imageUrlFor logic downstream of it).
Known limitations
- Playback always goes through
hls.jsagainst whatever Emby'sPlaybackInfosuggests. If that ever comes back as a direct-play source with noTranscodingUrl(no transcoding needed/possible), the client has no fallback path yet - it'll try to hand hls.js something that isn't HLS. - One fixed item/episode-queue per video party (a music party's queue is the live, participative one - see "Music" above).
- A video 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. Music has no equivalent heartbeat
at all (a deliberate simplification, not an oversight - see "Music"
above): position only resyncs on a discrete event (add/skip/pause/
resume), so two listeners'
<audio>elements could drift apart slightly during a long stretch with no such event - accepted for v1 given music's more forgiving tolerance for exact sync compared to video+subtitles. - No persistence: server restart or long idle time ends every party.
- No free seeking within a music track (only vote-skip to the next one)
- not asked for, and would need its own protocol addition
(
MusicQueueServicehas no notion of "jump to position X" today, only play/pause and advance).
- not asked for, and would need its own protocol addition
(
- The self-hosted burn-in stream (see "Self-hosted ffmpeg" above) transcodes forward from the start in real time - seeking far ahead of what ffmpeg has produced so far will stall until it catches up; there's no "restart ffmpeg at the seek target" logic yet, unlike Emby's own transcode for every other stream. Its direct-source URL is also the one Emby-shaped assumption in this whole app that's never been confirmed against a real server.
- The Emby-playstate sync (see its own section above) only ever reports progress, never an explicit start/stop - Emby's "now playing" session list won't show this app's streams, and "mark fully watched" isn't guaranteed to fire the way a clean Stop near 100% would trigger it. A video follower's reported position is the host's tracked position, not their own exact one (there's no per-follower tracking today) - a reasonable approximation given the player's own drift correction, not a precise per-viewer number.
- Now that every party shares one Emby stream (see "One shared Emby stream per party" above), a rough patch in Emby's own transcode/seek handling is felt by the whole party at once instead of just the one viewer who caused it - not a new failure mode (Emby's own engine is unchanged), just a wider blast radius for whatever it already was. Audio track/quality changing from a per-viewer to a host-only, party-wide choice is the explicit, intended trade-off for one shared stream, not a limitation to fix - see that section for why.