New mediaSession.ts wraps the standard Media Session API - shared by Player.ts and MusicBar.ts, which can never both be active at once so there is no risk of them fighting over this one global. Metadata/ position/playback-state hook into each players own existing single source of truth (updatePlayPauseBtn/updateProgressBar, setPlayIcon/ updatePositionDisplay) instead of adding parallel call sites. Play/ pause/seek are wired once and read isHost/conn fresh on every OS-triggered call, so they keep working through a live host handoff; a non-host play/pause routes through the same requestPause/ requestResume the request toolbar already uses, and seek is gated on isHost the same way the in-page seek bar already is. Next is handled separately since a registered nexttrack handler is what makes the OS show that control at all - its presence tracks whatever the in-page equivalent already tracks (isHost && hasNextQueued for video, a vote skip for music). Cleared entirely on leaving a party/page, or just to idle (metadata/state only) when a listen partys queue runs empty but the party itself is still open. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
408 lines
24 KiB
Markdown
408 lines
24 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
|
|
- [x] Skip intro / jump to next episode at end credits, using Emby's own
|
|
native marker detection (`EnableMarkerDetection` + its "Detect
|
|
Episode Intros" task) rather than building fingerprinting
|
|
ourselves — see docs/architecture.md
|
|
- [x] Report each party member's own playback to Emby's real Sessions
|
|
API (`/Sessions/Playing`, `/Progress`, `/Stopped`), so the admin
|
|
dashboard sees watch-party streams the same way it sees a native
|
|
client's — one session per member, under their own account — see
|
|
docs/architecture.md
|
|
- [x] Queue a whole album, or an artist's entire discography, in one
|
|
action — resolved server-side (one atomic operation, not one
|
|
request per track) the same way launching a playlist already
|
|
expands into everything it contains — see docs/architecture.md
|
|
- [x] Picture-in-picture for the video player (standard
|
|
`requestPictureInPicture`, confirmed present on Android Chrome) —
|
|
asked for directly for mobile, where there's no second window to
|
|
put this app in while switching apps — see docs/architecture.md
|
|
- [x] Installable as a PWA (manifest + icons, standalone display,
|
|
no service worker — this app has nothing worth caching offline)
|
|
— see docs/architecture.md
|
|
- [x] Mobile navigation redesigned as a bottom sheet (Accueil /
|
|
Bibliothèques / Listes de lecture) instead of a horizontally
|
|
scrolling sidebar — see docs/architecture.md
|
|
- [x] Music UI reworked to look like a music app instead of reusing
|
|
movie-poster tiles — circular artist tiles, square album tiles,
|
|
a real hero header, and a mobile "now playing" full-screen player
|
|
— see docs/architecture.md
|
|
- [x] Mobile menu's bottom sheet is now swipe-to-close (a real drag
|
|
handle, not just a decorative grabber) instead of fighting the
|
|
browser's own pull-to-refresh gesture — see docs/architecture.md
|
|
- [x] Every blocking `alert()` (host handoff, every "couldn't start/join
|
|
a party" error) replaced with a light, auto-dismissing toast — see
|
|
docs/architecture.md
|
|
- [x] Library/artist grid tiles shrink fluidly on a narrow phone instead
|
|
of a fixed 160px floor collapsing to one column per row — see
|
|
docs/architecture.md
|
|
- [x] Library page title now stays pinned alongside the search bar while
|
|
scrolling, instead of just the search bar — see docs/architecture.md
|
|
- [x] Library alphabet index: sticky per-letter section headings plus a
|
|
right-edge A-Z rail to jump straight to one, drag-to-scrub like
|
|
iOS's own Contacts — see docs/architecture.md
|
|
- [x] Fixed the alphabet index's headers landing in the wrong order —
|
|
it was bucketing by display name instead of Emby's own SortName,
|
|
so an article-stripped title ("The ...") threw a whole run off —
|
|
see docs/architecture.md
|
|
- [x] Fixed the music bar's queue popup — a missing `position` broke
|
|
the desktop layout when opened and left it unclosable on mobile
|
|
(its own close button was reachable now) — see docs/architecture.md
|
|
- [x] Fixed library tile buttons rendering on top of the sticky header
|
|
while scrolling — an equal z-index tie that a scrolling tile won
|
|
— see docs/architecture.md
|
|
- [x] Fixed the music bar's desktop layout for real — the progress bar
|
|
was missing a grid-column span, so it (and not just the queue
|
|
popup above) was still squeezed and wrapping controls onto a
|
|
second row — see docs/architecture.md
|
|
- [x] Fixed an album's track rows collapsing to 3 lines on mobile —
|
|
there was always room for one, the breakpoint just assumed
|
|
otherwise — see docs/architecture.md
|
|
- [x] A mobile banner nudging an install of the PWA — the real
|
|
Chrome/Android install prompt, manual instructions on iOS Safari
|
|
(no such prompt exists there) — see docs/architecture.md
|
|
- [x] Linked whatever's playing (video or music) to the OS's own media
|
|
controls (Windows' SMTC, macOS' Now Playing, ...) — title/artwork,
|
|
play/pause/seek/next — 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).
|