watchparty/README.md
Nicolas b2310216ce
Some checks failed
Publier l'image Docker / publish (push) Has been skipped
Publier l'image Docker / test (push) Failing after 8s
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>
2026-09-14 10:43:12 +02:00

223 lines
12 KiB
Markdown

# Watch Party for Emby
A standalone web app for watching the same title, in sync, with other users
of the same Emby server. It reads your Emby catalog and streams your Emby
media, but it's its own app with its own player — not an Emby plugin.
See [docs/architecture.md](docs/architecture.md) for the full design,
including why this replaced an earlier Emby-plugin attempt (still in
[src/WatchParty/](src/WatchParty/) for reference — see below).
## How it works
1. Sign in with your Emby account (just username/password — the server
address is fixed server-side, see Deploying below).
2. A sidebar lists your Emby libraries (movies, TV, music, music videos —
anything else Emby might have, like photos or a boxset collection, isn't
shown); the home page shows every ongoing
watch party live (host, how long it's been running, member count) plus a
"recently added" row per library, Emby-style. No join codes — if a party
is running, it's already on the list, updating in real time.
3. Pick a movie/episode tile and hit "Lancer une watch party" (or drill into
a Series' seasons/episodes first) — you become the host — or hit
"Rejoindre" on any party already listed on the home page.
4. The host's play/pause/seek drives everyone else's player automatically;
starting from an episode queues the rest of the series so the host can
just hit "Épisode suivant" instead of restarting a party each time. If
the host leaves, the party hands off to another member automatically
(their player unlocks live, no reload needed) — only closing once
everyone's gone.
5. Everyone present shows up as a small avatar overlay on the video (their
initials, on the player itself). If you're not the host, clicking your
own avatar asks the host to pause — everyone sees the request, including
the host, without needing chat.
6. Music works differently, on purpose: pick a track (browsing Artist →
Album → Track) to start a listening party. There's no host for
playback — anyone can add more tracks to the shared queue at any time,
anyone can pause/resume, and skipping the current track needs a
majority vote from whoever's currently listening. The player itself is
a persistent bar docked at the bottom of the screen, not a page — the
sidebar and the rest of the app stay fully usable while it plays, so
adding more tracks is just browsing to an album and clicking a track
(its button reads "Ajouter à la file" instead of "Écouter ensemble"
once a party's already going). Starting or joining a video watch party
always leaves the listen party first — the two don't run at once.
Everything — catalog, images, video, audio, sync — flows through this
app's own backend, which proxies whatever it needs from Emby server-side.
The browser never talks to Emby directly and never holds an Emby access
token; it only ever has a session cookie for this app.
## Status
Login, and the rendering/navigation logic for every page (sidebar, home,
library, series, artist/album, both players), have been exercised end to
end (login against the real Emby server with intentionally-wrong
credentials; the pages against mocked API responses; the music queue's
timer/vote/pause logic against the real server modules directly - no Emby
involved there at all). See
[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for
exactly what that does and doesn't confirm — the real Emby response shapes
this now depends on (library `CollectionType`, seasons/episodes, artists/
albums/tracks, `PlaybackInfo`'s media streams) and actual HLS playback
(video or audio) are still unverified.
The TypeScript rewrite itself is separately verified: both projects
type-check in strict mode, the server has a real automated test suite
(`server/test/*.test.ts`, Node's built-in test runner, see Running
locally below) covering the party lifecycle, the music queue's
timer/vote/pause behavior, the socket protocol, and the catalog/streaming
overrides, and the compiled+bundled client was driven through every page
and both players end to end (mocked network/Socket.IO) in a real browser.
CI (Forgejo Actions) runs that same typecheck+test+build sequence on
every push and gates the Docker image publish on it passing — see
[docs/architecture.md](docs/architecture.md#continuous-integration).
**Not verified**: an actual `docker build` of the new multi-stage
Dockerfile - this dev environment has no Docker - so treat the first real
build as the thing to double-check, even though it mirrors exactly the
`npm run build` commands already confirmed working locally for both
projects.
## Project layout
Both `server/` and `client/` are TypeScript now (see
[docs/architecture.md](docs/architecture.md#typescript-and-a-service-oriented-architecture)
for the why and the migration itself) - each its own project with its own
`package.json`/`tsconfig.json`/build step; `server/src` and `client/src`
are the only source of truth, `server/dist`/`client/dist` are generated
(gitignored) and never hand-edited.
```
server/ Node.js + Express + Socket.IO backend: proxies ALL Emby
access (auth, catalog, images, HLS streaming) and
coordinates party/room state + realtime sync. The
browser only ever talks to this. TypeScript, compiled by
tsc to server/dist.
src/index.ts Express app + route wiring only - thin,
delegates to the services below
src/config.ts env-derived config (EMBY_SERVER_URL, PORT)
src/types.ts shared interfaces (Party, ItemDto, the
Socket.IO event maps, ...)
src/Logger.ts party/stream lifecycle logging
src/emby/EmbyClient.ts low-level Emby HTTP + response proxying (incl. HLS)
src/services/AuthService.ts server-side login + session store
src/services/CatalogService.ts all Emby browsing/streaming + DTO shaping
src/services/PartyService.ts in-memory party lifecycle (video queue, host handoff)
src/services/MusicQueueService.ts participative music queue - see docs/architecture.md
src/realtime/SocketGateway.ts Socket.IO protocol (see comments in the file)
client/ Frontend: TypeScript, esbuild-bundled to client/dist
(one app.js + one app.css - see client/build.mjs).
Talks only to server/ - never to Emby directly. Each
view is its own folder colocating its .ts/.html/.css.
src/app.ts entry point - registers routes, boots the app
src/router.ts tiny hash router (unchanged in spirit - see docs)
src/types.ts shared interfaces (client-side copies of the
server's DTO shapes - see docs/architecture.md)
src/api/embyApi.ts calls our own /api/catalog/* + auth routes
src/api/party.ts our own backend's party REST + Socket.IO client
src/tiles.ts + tiles.css poster-tile rendering shared by home/library/artist
src/styles/ base.css (global reset/vars/primitives) +
small shared component styles (presence,
detail header, track list)
src/shell/Shell.* persistent sidebar + #mainContent the router swaps
src/musicBar/MusicBar.* persistent bottom-bar listen party - NOT a route,
mounted once by Shell.ts so it survives navigation
(see docs/architecture.md's "Music bar" section)
src/views/login/Login.* sign-in form
src/views/home/Home.* live parties carousel + per-library "recently added"
src/views/library/Library.* one library's tile grid (or artist grid, for music)
src/views/series/Series.* Series detail + season tabs + episode list
src/views/artist/Artist.* one artist's albums
src/views/album/Album.* one album's tracks - each track starts or queues
onto the active listen party (see musicBar/ above)
src/views/player/Player.* the /party/:id route's dispatcher (video takeover,
or hands off to musicBar/ + bounces back for music)
src/WatchParty/ The earlier Emby-plugin attempt (C#, unrelated to the
TypeScript above). Builds fine and installs, but Emby
has no supported extension point for a player button,
and even menu integration required reverse-engineering
undocumented internals - see docs/architecture.md for
the full story. Kept for reference; not the active
direction.
docs/architecture.md full design doc for the standalone app
```
## Running locally
Both projects need `npm install` once; the server needs the client
already built at least once (it serves `client/dist` as static files).
```bash
cd client && npm install && npm run build && cd ..
cd server && npm install && npm run build
EMBY_SERVER_URL=https://your-emby-server npm start
```
Then open `http://localhost:3000`.
For active development, `client`'s `npm run watch` rebuilds on save, and
`server`'s `npm run dev` runs the server straight from TypeScript via
`tsx` (no separate compile step needed while iterating) - run both in
separate terminals.
Server tests (`server/test/*.test.ts`, Node's built-in test runner, no
extra dependencies or compile step):
```bash
cd server && npm test
```
`server`'s `npm run typecheck` also covers `server/test/`, not just `src/`
(see `tsconfig.test.json`).
## Deploying
Build the Docker image from the **repo root** (it needs both `server/` and
`client/`) - the Dockerfile compiles both TypeScript projects itself in a
build stage, so nothing needs building beforehand:
```bash
docker build -f server/Dockerfile -t watchparty .
docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty
```
Then put it behind your existing reverse proxy like everything else
(`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g.
`watchparty.kyuno.fr` → `http://<container>:3000`.
## Roadmap
- [x] Design + scaffold the standalone app
- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
- [x] Player language/subtitle/quality pickers
- [x] Socket.IO instead of raw WebSockets; fixed two real memory leaks
(dead-connection cleanup server-side, view teardown on navigation
client-side) — see docs/architecture.md
- [x] Live "watch parties en cours" list (host, elapsed time, member count),
live host handoff, no join codes
- [x] Presence overlay (avatars) + "request pause" in the player
- [x] Server-side logging of the party lifecycle (create/join/leave/destroy,
host handoff, play/pause, pause requests) — see docs/architecture.md
- [x] Music: dedicated player, Artist/Album/Track browsing, participative
queue (free add, vote-to-skip, no host) — see docs/architecture.md
- [x] Full TypeScript rewrite (client + server), component-style client
views (.ts/.html/.css each), server-side singleton services per
feature — see docs/architecture.md
- [x] Automated server test suite (`node:test`) + CI job gating the Docker
publish on typecheck+test+build passing — see docs/architecture.md
- [x] Music player as a persistent bottom bar instead of a page — browse
and queue tracks while listening; starting a video party always
leaves the listen party first — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Tune transcoding target / support direct play when possible
No chat, no OIDC — deliberately out of scope (Emby-only auth was the
explicit choice; see docs/architecture.md).
## License
MIT — see [LICENSE](LICENSE).