5 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
| a7944538bc |
Replace native <video controls> with custom transport controls
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> |
|||
| b4c81d820c |
Cast a party's video to a Chromecast
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>
|
|||
| fff0b7a984 |
Fix subtitle selection timing out: hls.js's default timeouts too tight
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> |
|||
| b2310216ce |
Fix settings menu visibility bug; make the music player a persistent bar
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>
|
|||
| 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> |