Commit graph

5 commits

Author SHA1 Message Date
a7944538bc Replace native <video controls> with custom transport controls
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 7s
Reported right after trying the player rework: the action bar looked
like two z-index layers with some elements duplicated, only in
fullscreen. Root cause: video.controls = true (set for the host since
this player's first version) has always rendered the browser's own
native control bar - a separate piece of UI in its own always-on-top
compositing layer, independent of this app's DOM/CSS. That was never
visible before, since fullscreen used to show only the video; once it
correctly started showing this app's own overlays too, the native bar
and the custom one both became visible, and specifically duplicated -
Chrome adds its own cast icon to a <video>'s native controls once it
detects a Cast-capable device, entirely separate from #castBtn's own
integration.

Two fixes:
- disableremoteplayback on <video> - the standard attribute telling
  the browser not to add its own cast icon, since this app has its own.
  Should have shipped with the original Chromecast feature.
- Native <video controls> retired entirely, everywhere (not just
  fullscreen), replaced with a custom transport bar - asked for
  directly rather than just suppressing the symptom in fullscreen.

PlaybackDriver (cast.ts) gained `duration` alongside currentTime/paused -
the one thing a seek bar needs that the existing local/cast abstraction
didn't yet expose. #bottomBar gained a seek row (progressBar/
progressFill) above the existing button row - host drags/clicks to seek
by fraction of activeDriver.duration (pointer events, mouse and touch
share one path); a follower's bar is the same element, read-only. A
500ms ticker keeps the fill/time label moving between discrete events.
#playPauseBtn (host-only) and #muteBtn (everyone, local-only - never
synced, hidden while casting since the local <video> is silent then)
round out the replacement. One function, onDriverStateChanged, now
threads through every discrete play/pause/seek event (the local
<video>'s native events, and a cast session's RemotePlayerController
events) so the button icon, the seek bar, and the party broadcast stay
in lockstep regardless of which driver or gesture caused the change.

Verified: full typecheck/build. Checked in a real browser against the
compiled bundle - injected the new markup for both a playing/unmuted
and a paused/muted state, confirmed icons/fill/time label/readOnly
cursor all render as designed. Not verified: a real drag-to-seek
gesture or real playback - no reachable Emby stream in this sandbox to
attach a real <video> source to.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 11:30:50 +02:00
b4c81d820c Cast a party's video to a Chromecast
All checks were successful
Publier l'image Docker / test (push) Successful in 20s
Publier l'image Docker / publish (push) Successful in 16s
Implements a detailed spec (Google Cast / Chromecast integration) asking
for a member to cast the party's video to a Chromecast's Default Media
Receiver, with play/pause/seek shown as a remote on the sender's own
screen. The spec's description of this app's architecture (one shared
Emby stream per party, the server proxying 100% of Emby access) was
verified directly against the real code before building anything, since
it named specific existing services/routes as the integration points.

A Chromecast is a new kind of client for the existing party-stream/burn
relay: it can't carry the sender's httpOnly session cookie at all, so it
needed its own narrow way in without weakening "the server holds the
only real Emby token" the rest of the app is built around.

- New CastTokenService (same shape as AuthService: in-memory Map, opaque
  crypto.randomUUID() keys, TTL-checked) issues a token scoped to one
  {partyId, userId}, standing in for that member's own EmbySession.
  New POST/DELETE /api/parties/:partyId/cast-token to mint/revoke one.
- The 4 routes a Chromecast actually needs (party-stream/master.m3u8,
  party-stream/raw, burn/master.m3u8, burn/:segment) gained one small
  fallback (sessionForStream in index.ts): try the session cookie first,
  same as ever, then a castToken query param - either way the result is
  the same EmbySession type, so PartyStreamService/TranscodeService/
  EmbyClient needed zero changes. The image/subtitle routes stay
  cookie-only, outside the spec's own 4-route list.
- PartyService.leave() now revokes whatever cast token the leaving
  member held, unconditionally - a token dies the instant its owner
  actually leaves the party regardless of TTL or whether the client got
  a chance to call the explicit stop-casting route.
- New client/src/cast.ts is the only module touching the CAF Sender SDK
  (loaded as a pinned CDN script, same pattern as hls.js/socket.io) -
  exposes a small PlaybackDriver interface (currentTime/paused/play/
  pause) with two implementations, one over the local <video>, one over
  CAF's RemotePlayer/RemotePlayerController. This was the only new
  abstraction the feature needed, not a new sync mechanism: Player.ts's
  existing sendLocalState/applyRemoteState already read and write "the
  player" in one place each, so pointing them at whichever driver is
  active carried the host-only gate, drift-tolerance reconciliation, and
  3s heartbeat over unchanged. Seek was already "the host's next
  broadcast" with no separate event, so seeking on the TV is party-wide
  for free; a non-host caster already only ever received state, so
  redirecting that to the cast driver reproduces "still dependent on the
  host" automatically.
- Cast button in the player's action bar, shown to any member (not just
  the host) once cast.framework reports a receiver on the network.
  Starting a cast tears down the local hls.js instance; ending one
  (user-stopped, or the receiver dropping off on its own) resyncs local
  playback via the same loadVideo path an audio/quality change already
  uses. A real reload for any other reason (item change, burn-in/
  subtitle/stream-options change) simply ends an active cast first,
  rather than keeping a live session in sync with every kind of party
  transition - not worth the complexity for v1.

Scoped to video parties only - the spec's every concrete reference is
video-specific, and MusicBar.ts has no comparable control surface today.

Tests: CastTokenService (issue/resolve/reissue-invalidates-the-old-one/
revoke/wrong-party-rejection), PartyService.leave()'s new call. Full
typecheck/test/build clean on both projects (115/115 server tests).
Confirmed against the real running server that the 4 streaming routes
still reject a request with no auth exactly as before, and now also
reject an invalid castToken - both a plain 401, no regression. Confirmed
in a real browser that the Cast SDK loads and initializes correctly. Not
verified: no real Chromecast or Emby login credentials in this
environment to take a cast session, or the button's own live behavior,
any further - see docs/architecture.md's "Casting to a Chromecast"
section.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 10:13:12 +02:00
fff0b7a984 Fix subtitle selection timing out: hls.js's default timeouts too tight
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 9s
Reported: selecting a subtitle track (only subtitles, not audio/quality)
made the request "time out". Confirmed from the browser's own error:
hls.js gave up with a fatal fragLoadTimeOut after 4 retries, each hitting
its default 10s fragment-load timeout - not a hang, and not a repeat of
the earlier "override doesn't reach Emby" bug (see docs/architecture.md's
Player-track-selection section) - the same error's frag.baseurl already
shows SubtitleMethod=Encode correctly present in the request Emby
received, so the override chain works end to end. It's just slow: burning
a subtitle in forces a real re-encode (not a rendition, not a remux),
which measurably takes Emby longer than 10s to produce a first segment
for.

Fixed by giving both players' Hls instances explicit longer timeouts
(manifestLoadingTimeOut/levelLoadingTimeOut: 30s, fragLoadingTimeOut: 60s)
instead of hls.js's untouched defaults - applied to musicBar/MusicBar.ts
too, not just the video player, since music tracks go through the same
PlaybackInfo/transcode pipeline and could hit the same latency for a
quality change even without subtitles being involved there today.

global.d.ts's HlsStatic constructor gained an optional HlsConfig
parameter (only the four properties actually used) so this doesn't need
an `as any`/unsafe cast to pass config through.

Verified the property names are real and actually honored by hls.js
1.5.17 (the exact pinned CDN build), not silently ignored: constructed a
real Hls instance with this config and read back hls.config.* - all four
values came back as set, not as hls.js's own (tighter) defaults.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 13:11:06 +02:00
b2310216ce Fix settings menu visibility bug; make the music player a persistent bar
Some checks failed
Publier l'image Docker / publish (push) Has been skipped
Publier l'image Docker / test (push) Failing after 8s
Fix: player settings menu stuck permanently open
----------------------------------------------
.hide { display: none; } and Player.css's .settingsPanel { display: flex;
... } are both single-class selectors - equal specificity - so whichever
one esbuild happened to bundle later into app.css won the `display`
property on an element carrying both classes. Player.css's own
display:flex always landed after base.css's .hide, so #settingsPanel
showed regardless of the hide class ever being toggled - the toggle logic
itself was correct the whole time, it just had no visible effect.

Fixed at the utility class, not the one call site: `.hide` now uses
`display: none !important`, which wins a same-specificity tie regardless
of bundling order - closes off the whole bug class, since several other
components already set their own `display` for a visible state.

Feature: music player is now a persistent bottom bar, not a page
------------------------------------------------------------------
The music player was its own route (/party/:id -> views/musicPlayer/),
which meant navigating anywhere else - the sidebar, an album to queue up
more tracks - tore the whole thing down (router.ts calls a route's
cleanup, which used to call leaveParty, on every hash change). No way to
browse and listen at the same time.

- client/src/musicBar/MusicBar.ts (new): a Shell-level singleton, mounted
  once into a #musicBar element that's a sibling of #mainContent, never
  touched by route changes. Renders as a fixed bottom bar. Exposes
  initMusicBar/joinMusicParty/leaveMusicParty instead of the router's
  render()-returns-cleanup pattern, since it never registers as a route.
- views/player/Player.ts's dispatcher: a music party no longer renders a
  page - it calls joinMusicParty() and bounces back to /home (a no-op for
  every reachable call site except album's). A video party now calls
  leaveMusicParty() first (harmless no-op if nothing's active) - starting
  or joining a video watch party always leaves the listen party, since
  every party-join call site already funnels through this one dispatcher.
- views/album/Album.ts: the last per-track "start a party" UI now that
  the music player's own inline browse panel is gone (browsing just
  means navigating there normally). Each track's button starts a new
  party or adds to whichever one is already active, decided by
  getActiveMusicPartyId() at CLICK time (not render time) so the action
  taken is always correct even if the label is momentarily stale; every
  button is relabeled in place after a successful click. Starting a
  party from here no longer navigates away at all.
- views/musicPlayer/ deleted entirely (MusicPlayer.ts/.html/.css) -
  superseded by musicBar/.
- Comment updates in global.d.ts/presence.css/trackList.css/party.ts/
  types.ts that named the old module by path.

Verified both fixes by rebuilding and driving the real compiled bundle
through a local static server, with fetch/window.io/window.Hls mocked:
starting a party from an album track leaves the sidebar/page in place and
relabels the other tracks; adding a track posts to the already-active
party's id; navigating into a video party hides the bar, disconnects its
socket, and posts a real leave for the music party before the video
player renders; the settings gear opens/closes correctly for the host in
that same run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 10:43:12 +02:00
4a31ee741e Rewrite server + client in TypeScript, service-oriented server architecture
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
Full rewrite, requested directly. Two independent decisions since
TypeScript can't compile itself away at runtime - "no build step" and
"TypeScript" are mutually exclusive, not a small addition to the existing
setup:
- Server: plain tsc (no bundling need - Node resolves its own requires).
  server/dist mirrors server/src 1:1.
- Client: esbuild (does need bundling) - picked over a heavier tool
  (Vite, webpack) since this app's whole client is a couple thousand
  lines; its built-in CSS-from-JS bundling and a `text` loader for
  importing an HTML template as a string cover everything needed via a
  ~30-line hand-written build script (client/build.mjs), not a config
  file for something built for much bigger apps.
Both projects compile independently (no shared-types package - considered
and rejected as more structural commitment than this app's size
justifies; server/src/types.ts and client/src/types.ts have their own
overlapping DTO copies instead). server/'s `npm run dev` uses tsx for a
compile-free dev loop; `npm run build` (tsc) is what server/Dockerfile's
build stage actually runs. client/'s `npm run watch` is esbuild's own
watch mode.

Server: one singleton service class per feature, generalizing the
PartyManager/MusicQueueService pattern that already existed, rather than
route handlers with logic inline:
- AuthService (from embyAuth.js), CatalogService (NEW - previously ~15
  route handlers' worth of Emby queries/DTO shaping inlined directly in
  index.js), PartyService (renamed from PartyManager), MusicQueueService
  (ported as-is, already service-shaped), SocketGateway (from
  socketHandler.js), EmbyClient (from embyProxy.js - low-level plumbing,
  not called a "service" since it has no state/feature of its own).
- index.ts is now thin wiring only: every route reads the session, calls
  one service method, returns the result.
- Real error-handling pass, not just a port: Express 4 doesn't catch a
  rejected Promise from an async handler on its own - a new asyncRoute()
  wrapper + a single error-handling middleware replace what used to be
  zero handling for a request that outright throws. CatalogService
  methods throw a CatalogFetchError (own HTTP status - getRawItem now
  preserves Emby's real 404 instead of a blanket 502) rather than
  touching res directly.

Client: one folder per view (src/views/<name>/<Name>.ts + .html + .css)
instead of one .js file with the markup inline as a template literal. A
view's static shell moved to its .html (esbuild's text loader); per-item
dynamic content (tile grids, episode/track lists) stays as template
-literal-returning functions in the .ts, same as before. Genuinely shared
CSS (tiles, the presence avatar, the series/album detail-header pattern,
track-list rows) lives under src/styles/, imported by whichever views
need it; src/styles/base.css holds the global primitives, imported once
by app.ts. Deliberately not pushed to zero duplication/renaming shared
classes across views for tidiness alone - not worth the risk in a change
already this large. Player.ts keeps its pre-existing internal shape (the
/party/:id dispatcher + the video renderer in one file, importing
renderMusicPlayer from the sibling folder) - that pattern predates this
rewrite and only needed types, not restructuring.

Two real things the port itself caught, not just carried over:
- A type bug: porting SocketGateway's "members" broadcast, reusing
  PartyService's PublicMember interface would have type-checked fine but
  was the wrong (larger) shape - the socket broadcast never included
  isPaused/positionTicks, only the REST response did. Caught by
  comparing against the original JS line by line, fixed with a dedicated
  SocketMemberDto type.
- A real Express+TypeScript friction point: noUncheckedIndexedAccess
  (enabled in both tsconfigs - genuinely useful elsewhere, e.g.
  PartyService.advanceQueue's array indexing) makes every req.params.xxx
  read a type error, since Express's index-signature typing doesn't know
  a matched route's required :name segment is always present. Resolved
  once with a param(req, name) helper rather than 15 routes' worth of
  scattered assertions.

Verified: both projects type-check cleanly in strict mode (tsc --noEmit -
not just "esbuild didn't complain", which it wouldn't even with type
errors present, since it transpiles without checking). Server: every
unit/integration test from the JS version (party lifecycle logging, the
music queue's timer/vote/pause behavior incl. real setTimeout
auto-advance, the socket integration through SocketGateway+PartyService+
MusicQueueService together, the library-type filter, the playback
-override forcing) re-run against the compiled server/dist output -
identical results; the compiled server was actually started and confirmed
listening cleanly. Client: since esbuild bundles everything into one
app.js (no more individually-importable view modules), testing moved to
driving the real compiled bundle through its actual boot -> login ->
router -> view chain (mocked fetch/Socket.IO/hls.js, real clicks/hash
navigation) - arguably more realistic than the old per-module approach.
Confirmed this way: login, the sidebar, home's carousels, a video party
(presence, host controls, leave), the full music hierarchy, a music party
(presence, vote-skip, pause toggle, the inline browse-and-add panel), and
the series page - all through client/dist's actual output, no thrown
errors anywhere.

Not verified: an actual `docker build` of the new multi-stage Dockerfile -
this dev environment has no Docker at all. The build stage runs the exact
npm run build commands already confirmed working locally, just inside
node:20-alpine - the main remaining risk is something environment
-specific, not the build commands themselves, but a real build is still
the first thing to try after this lands.

docs/architecture.md has the full writeup (new "TypeScript and a
service-oriented architecture" section, plus a corrected top-level
"Components" overview that had gone stale even before this change).

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