Two small, unrelated requests handled together. The search bar (tiles.ts's shared .searchBar, used by views/library/ artist/album) scrolled away with the page - asked to stay reachable while scrolling a long grid. position: sticky; top: 0 on the input itself; base.css's own generic input rule already gives it an opaque background, which is what keeps tiles scrolling underneath from showing through once it's 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 MusicQueueService had no seek concept, just play/pause/advance. The permission model was already decided by that class's own documented reasoning for why *anyone* can pause/resume (reversible, doesn't change what's playing) - a seek is exactly that same shape, continuous instead of binary, so it gets the same treatment, not voteSkip's majority gate. MusicQueueService.seek mirrors setPaused's own elapsed-time math (clamped into [0, durationSeconds]), with one real difference: setPaused's resume branch gets away with calling scheduleAdvance directly only because pausing already cleared the timer first - scheduleAdvance itself never clears a prior one. Seeking while still playing has no such guarantee, so skipping that clear first would leave the OLD timer (counting down against the old position) 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's progress track gained the same pointerdown/move/up drag-to-seek handlers the video player's seek bar already has - no host branch to port this time, since a listen party has none - plus 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 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 needed for the sticky search bar - 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). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
344 lines
20 KiB
Markdown
344 lines
20 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,
|
|
playlists if you have any — 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. Already
|
|
connected to that same party from another device? You're asked to
|
|
confirm before it gets disconnected — reconnecting from the same
|
|
device (a reload, a second tab) never asks.
|
|
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. Everyone also shares the exact same stream from
|
|
Emby — one transcode per party, not one per person — so the host's
|
|
language/quality pick applies for the whole party too.
|
|
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, a small
|
|
toolbar lets you ask the host to pause or resume — everyone sees the
|
|
request, including the host, without needing chat.
|
|
6. Subtitles are a host-only, party-wide pick too, like the
|
|
language/quality pick above — the host picks one (or "Aucun") and
|
|
everyone's player follows. Any text-based track just renders
|
|
natively in each viewer's own browser, in sync (no reload, no extra
|
|
server load — only *which* one is synced). A Blu-ray/DVD-style image
|
|
subtitle (PGS/VobSub/DVB) is different — picking one has this app's
|
|
own ffmpeg burn it into the video, shared live by everyone in the
|
|
party, which does restart the stream for everyone since the video
|
|
itself changes.
|
|
7. 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.
|
|
8. A playlist works the same idea as a series: open it to see what's
|
|
inside, then "Lancer cette playlist" starts one party pre-loaded with
|
|
everything in it — a video queue if it's movies/episodes, or the
|
|
participative music queue if it's tracks.
|
|
9. While you're in a party, your own progress is quietly pushed back to
|
|
your own Emby account (resume position, last-played state) — so
|
|
opening Emby's own app afterward reflects what you actually watched
|
|
or listened to, not just this app's own memory of it.
|
|
|
|
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. Same caveat for the self-hosted ffmpeg burn-in pipeline (see
|
|
docs/architecture.md): no ffmpeg binary is reachable here, so while its
|
|
pure logic (stream-index conversion, session lifecycle) is unit-tested
|
|
with `child_process.spawn` mocked, the actual command against a real
|
|
PGS file has never run in this environment. Treat the first real
|
|
attempt at a Blu-ray-style subtitle as the thing to double-check here.
|
|
**Resolved**: three Emby-API assumptions that used to be listed here as
|
|
unconfirmed (the ffmpeg direct-stream URL, the Playlists endpoint, the
|
|
Emby-playstate sync endpoint) were all checked directly against Emby's
|
|
own real OpenAPI spec. Two matched exactly. The third - playstate sync
|
|
- took three rounds: two request-shape bugs against Emby's
|
|
`PlayingItems/Progress` check-in endpoint (a missing `MediaSourceId`,
|
|
then a missing `PlaySessionId`), then a further report ("still don't
|
|
see this in my Emby profile") that questioned the whole approach - a
|
|
bare `Progress` report with no matching "Start" call may just never
|
|
reach the resume-position/watched-history data real clients read,
|
|
regardless of request shape. Switched entirely to a plain
|
|
`POST /Users/{userId}/Items/{itemId}/UserData` instead - confirmed
|
|
directly against the real server that this is exactly the data model
|
|
"Continue Watching" and the watched checkmark read, updated with no
|
|
playback session required at all - and added marking an item Played
|
|
(the other half of the same report) when the party moves past it.
|
|
Not yet confirmed end to end against a real server (no safe way to
|
|
fire a real, state-mutating test call without one) - see
|
|
docs/architecture.md's "Sync watch-party progress back to Emby"
|
|
section for the full story. The one-shared-stream-per-party work is a
|
|
different kind of unverified: its own Emby API calls were already live,
|
|
confirmed code, just newly shared instead of per-viewer - what's untested is behavior
|
|
under real multi-viewer load (whether a dead Emby session actually
|
|
surfaces as an HTTP failure the way the cache-invalidation logic
|
|
assumes, how a real transcode's seek handling feels once several
|
|
viewers depend on the exact same one).
|
|
|
|
Casting to a Chromecast (see docs/architecture.md) is the newest,
|
|
least-verified piece, for video and music alike: the security-critical
|
|
parts were checked against the real running server directly (the
|
|
party-stream/burn routes reject a request with no auth exactly as
|
|
before, and now also reject an invalid `castToken`, both a plain 401)
|
|
and the Cast SDK itself was confirmed to load and initialize in a real
|
|
browser, but there's neither a real Chromecast nor Emby login
|
|
credentials in this environment to take it any further - the actual
|
|
on-TV playback, the cast button's own live behavior, and (for music) a
|
|
re-cast actually surviving a real track change are all unconfirmed.
|
|
|
|
## 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 (all three are now
|
|
host-only, party-wide choices — see docs/architecture.md's "One
|
|
shared Emby stream per party" and "Text subtitles join burn-in as
|
|
a party-wide, host-only pick")
|
|
- [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) + a request toolbar (pause/resume) in the
|
|
player — see docs/architecture.md
|
|
- [x] Device detection: reconnecting from the same device is silent, a
|
|
genuinely different device asks to confirm before disconnecting the
|
|
other one — see docs/architecture.md
|
|
- [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
|
|
- [x] Self-hosted ffmpeg burn-in for PGS/image subtitles (Emby's own
|
|
transcode can't render these as text) — one shared stream per
|
|
party, host-driven — see docs/architecture.md
|
|
- [x] Browse Emby playlists and launch one as a pre-queued party (video
|
|
queue or the participative music queue), either from the start or
|
|
from any chosen item ("Lire à partir d'ici") — see
|
|
docs/architecture.md
|
|
- [x] Sync watch-party progress back to Emby's own PlaystateService, per
|
|
member's own account, including marking an item watched once the
|
|
party moves past it — see docs/architecture.md
|
|
- [x] One shared Emby stream per party instead of one per viewer — see
|
|
docs/architecture.md
|
|
- [x] Text subtitle choice joins burn-in as a host-only, party-wide pick
|
|
(was still each viewer's own local choice until now); fixed the
|
|
audio picker briefly showing an empty option on first load — see
|
|
docs/architecture.md
|
|
- [x] Fixed catalog browsing feeling slow: image thumbnails were never
|
|
cacheable (Emby's own Cache-Control/ETag were silently dropped) —
|
|
see docs/architecture.md
|
|
- [x] Cast a party's video *or* listen-party music to a Chromecast
|
|
(Default Media Receiver), with a play/pause/seek remote on the
|
|
sender's own screen — music reuses the same `PlaybackDriver`/
|
|
`CastTokenService` the video case introduced, and re-casts
|
|
in place on every track change instead of dropping the session
|
|
the way an item change does for video — see docs/architecture.md
|
|
- [x] Fixed the listen-party queue's broken layout (it was reusing an
|
|
album page's row grid), its missing album art (a track almost
|
|
never has its own image — now falls back to its album's), and,
|
|
reported later, its fixed 480px width and a header that scrolled
|
|
away with the queue — see docs/architecture.md
|
|
- [x] Fixed a slow, freeze-prone catalog: a library grid's query fetched
|
|
an unused `Overview` field for every item, its artist list had no
|
|
cap at all, and up to 300 tiles were inserted in one synchronous
|
|
pass — see docs/architecture.md
|
|
- [x] Reworked the video player's bottom bar (episode/request actions/
|
|
fullscreen, one bar instead of three floating pieces) and added a
|
|
real fullscreen toggle — fullscreen now targets the whole player,
|
|
not just the `<video>`, so the request buttons stay visible; the
|
|
bar now also auto-hides down to just its seek line after a
|
|
stretch of no activity, so it stops masking subtitles — see
|
|
docs/architecture.md
|
|
- [x] Retired the browser's native `<video controls>` (it was rendering
|
|
as its own always-on-top bar, duplicating the cast icon and
|
|
overlapping the new bottom bar in fullscreen) in favor of fully
|
|
custom play/pause, a seek bar, and mute — see docs/architecture.md
|
|
- [x] Sticky search bar on the library/artist/album pages, and letting
|
|
anyone seek within the current music track (same no-host-check
|
|
model pause/resume already had) — see docs/architecture.md
|
|
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
|
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
|
|
confirmed here — no real device/session)
|
|
- [ ] 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).
|