` demande une pause") and flags the matching person's circle
(`.avatarPauseRequested`: an orange `#ff9800` border plus a small pause-icon
badge in its corner — `.avatarPauseBadge`, a plain inline two-bar SVG
rather than a Unicode glyph, since pause/play characters default to emoji
presentation on several platforms and look muddy at 10px) — both matched
by the event's `userId` against each circle's `data-user-id`, both clearing
automatically after `PAUSE_REQUEST_HIGHLIGHT_MS` (4s), for every member,
including the requester and the host — deliberately not host-only, so a
request landing while the host isn't looking at the tab is still visible
to everyone else, and the requester gets confirmation their own click
registered. This replaced an earlier plain box-shadow pulse animation and
a separate immediate-local-feedback ring on just the requester's own
circle (`.avatarSent`) — collapsed into one unambiguous visual language
driven purely by the server broadcast (which the requester also receives,
so there's no need for a separate client-only echo).
- `.followerNotice` picked up a `margin: 0` it was missing: it's a ``,
and for an absolutely positioned element `bottom` places the *margin*
edge, so its default ~1em user-agent margin was quietly rendering it 13px
higher than its declared `bottom: 24px` ever suggested. Harmless on its
own until `.pauseRequestBanner` needed to sit a specific gap above it —
found by measuring both rects and seeing the gap read as 2px instead of
the ~15px the two `bottom` values alone implied.
- Nothing about this feature actually pauses anything automatically — "request"
is literal, the host still has to act on it. Adding an actual forced-pause
would be a real design decision (can any member override the host's
playback state? that cuts against "host drives, everyone follows") and
wasn't asked for.
Verified end to end with mocked `members`/`pauseRequested` Socket.IO events
against the real running local server (see session history): the presence
row renders directly under the action bar; initials/colors render correctly
for a two-member party; the host's circle never gets `.avatarClickable`
(neither when viewed by themselves nor by others); a `pauseRequested` event
gives the right circle `rgb(255, 152, 0)` as its computed border color and
`display: flex` on its badge (confirmed via computed styles, not just class
names) while leaving every other circle untouched; rapid repeat clicks on
your own circle within the cooldown collapse to exactly one `requestPause`
emission (three synchronous clicks in one script → the emitted-event count
increased by exactly one — an earlier two-separate-clicks check had looked
like a cooldown failure at first, but that was real wall-clock time between
two separate tool calls exceeding the 5s window, not a code bug); and the
banner/`.followerNotice` gap measured 15px after the margin fix, versus 2px
before it. The host-only controls (`becomeHost`, settings menu,
next-episode button) were re-checked after this change and still work.
## 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.
## Assumptions to verify
**Confirmed against the real server** (not a guess — actually exercised,
see session history): `POST /Users/AuthenticateByName` really is reachable
from Node's server-side `fetch` with no CORS issue at all (server-to-server
has no CORS concept), returns a real 401 for bad credentials, and the whole
login round-trip (browser → our backend → real Emby → real rejection → our
error message) renders correctly end to end. The `--theme-primary-color-*`
etc. custom properties `client/css/app.css` is built from were read directly
off this exact server's live dark theme, not guessed.
**Login and basic usage are now confirmed for real** — updated once actual
bug reports started coming from real usage rather than mocked testing
(overlay/pause-request feedback, the series-detail request, and the
track-selection bug above, which is only reachable once a stream is already
playing). That means the following are confirmed working end to end against
this real server, not just reasoned about, even though none were checked in
isolation: response field names (`AccessToken`, `User.Id`/`Name`, item
`Id`/`Name`/`Overview`/`ProductionYear`/`Type`, `Views`' `CollectionType`,
`Shows/{id}/Seasons`/`Episodes`, `Items/Latest`'s plain-array shape,
`PlaybackInfo`'s `MediaStreams`), the `X-Emby-Authorization`/`X-Emby-Token`
header format, `rewritePlaylist` against a real Emby-generated playlist, and
`PlaybackInfo` → `TranscodingUrl` → HLS producing a real watchable stream.
None of this was independently exercised - it's inferred from "the features
built on top of it work in practice, and nobody's reported the classes of
failure that would follow from any of it being wrong" - worth keeping in
mind if something in this area breaks in a way that doesn't fit whatever
specific bug is being chased at the time.
**Genuinely not yet confirmed:**
- **`/Users/{userId}` for token validation** was dropped — the proxy
redesign no longer needs it at all (sessions are validated once at login
and cached server-side, not re-checked against Emby per-request).
- **Player track selection** (`AudioStreamIndex`/`SubtitleStreamIndex`
actually taking effect) — see the section just above; a fix is in place,
reasoned from how Emby's API is known to work, but not yet re-tested for
real.
- Whether `TranscodingUrl` is reliably present for every source type (a
direct-play-eligible source might not transcode at all, which `hls.js` on
the client wouldn't handle — see Known limitations), and whether it's
always HLS.
- **The Emby web client deep-link shape**
(`/web/index.html#!/item?id=X&serverId=Y`) used for "Voir sur Emby" —
matches the long-standing convention, not confirmed against this exact
build's routing (nobody's reported clicking it yet either way).
## UI: Emby-like shell, per-type browsing, episode queue
Reworked to look and navigate more like Emby itself, and to treat each
collection type (movies / TV / music) as its own experience rather than one
generic grid:
- **`client/js/shell.js`** renders the persistent chrome once per login: a
sidebar (collections from `GET /api/catalog/libraries`, one icon per
`collectionType`) + a `#mainContent` element the router (`router.js`)
swaps on every navigation. `router.js` was made idempotent
(`start(root)` can be called again safely) specifically so login → shell
boot can happen more than once in a page's lifetime without piling up
duplicate `hashchange` listeners — the same class of bug the earlier Emby
plugin work ran into with its page lifecycle, worth remembering.
- **`views/home.js`** (route `/home`): a horizontal carousel of every active
party server-wide (`GET /api/parties`, deliberately not filtered to "your"
parties — the point is letting people discover and join one without a
code passed out of band) plus a join-by-code fallback, then one more
carousel per library of its recently-added items
(`GET /api/catalog/libraries/:id/recent`, Emby's own
`/Users/{id}/Items/Latest`) — skipping music (see below).
- **`views/library.js`** (route `/library/:id`): dispatches purely on the
library's `collectionType`. `movies`/`tvshows`/anything else gets a tile
grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies
as movies, TV as Series tiles); `music` gets an honest "not built yet"
message instead of a half-working page (see Non-goals).
- **Tiles** (`client/js/tiles.js`, shared by the home carousels and the
library grid so they can't drift apart): poster + name, hover-revealed
overlay with "Lancer une watch party" (only for directly-playable types —
a Series tile gets "Voir les épisodes" instead, linking to `/series/:id`)
and "Voir sur Emby" (opens Emby's own web client via a `embyUrl` the
backend builds from `EMBY_SERVER_URL` + the session's `serverId`).
`:hover`/`:focus-within` reveal it on mouse/keyboard; `@media (hover: none)`
keeps it always visible on touch devices, which have no hover state at all.
- **`views/series.js`** (route `/series/:id`): a detail header — poster,
title + year, overview, a "Voir sur Emby" link — above season tabs
(`GET /api/catalog/series/:id/seasons`, Emby's `/Shows/{id}/Seasons`) and
an episode list per season (`GET /api/catalog/series/:id/episodes`, Emby's
`/Shows/{id}/Episodes?SeasonId=...`), each episode startable as its own
party. The header is just `toItemDto`'s existing fields
(`GET /api/catalog/item/:id`, the same DTO shape as everything else) —
the season/episode browsing itself predates this pass and was already
wired end to end (library grid → Series tile → this route → "Lancer une
watch party" per episode); what this pass added was making the page
actually look like a detail page instead of a bare `