diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..28306ee --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "watchparty-server", + "runtimeExecutable": "node", + "runtimeArgs": ["server/src/index.js"], + "port": 3000 + } + ] +} diff --git a/.gitignore b/.gitignore index bc9b986..f559d1a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,8 +9,10 @@ obj/ .idea/ *.swp -## Node (only relevant if control-panel tooling is ever added) +## Node (server/) node_modules/ +.env +.env.local ## OS cruft .DS_Store diff --git a/README.md b/README.md index a49bc06..934b2fa 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,94 @@ # Watch Party for Emby -An Emby Server plugin that lets a group of users on the same server watch the -same title together. Nobody switches players: everyone keeps using their -normal Emby app (web, mobile, TV, whatever), and the plugin nudges playback -back in sync using the same remote-control commands Emby already uses for -casting / "play on another device". +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 which parts are solidly confirmed against Emby's SDK docs and which -still need verification against your exact server build. +including why this replaced an earlier Emby-plugin attempt (still in +[src/WatchParty/](src/WatchParty/) for reference — see below). -## How it's meant to work +## How it works -1. You start playing a title in your normal Emby app. -2. In Emby's web client (or the Electron desktop app), open **Watch Party** - from the menu and start a party. You get a 6-character join code. No - separate login — it's a real page inside Emby, using your existing session. -3. Friends open the same **Watch Party** menu item, start playing the *same* - title in their own Emby app, and enter the code to join. -4. The server treats one member (the host, by default) as the reference - position, and periodically nudges everyone else's session back in sync - (seek correction, and mirroring pause/unpause) — see - [docs/architecture.md](docs/architecture.md#sync-algorithm). - -The Watch Party page is a companion panel, not a video player — it never -touches the actual video stream, playback stays in your normal Emby app. - -**Known limitation**: the menu item shows up on Web and Electron clients. -Official mobile/TV apps don't surface arbitrary plugin pages, so Watch Party -won't appear there — see [docs/architecture.md](docs/architecture.md#components). +1. Sign in with your Emby account. +2. Search your Emby catalog for a title. +3. Start a party (you become the host) or join one with a 6-character code. +4. The host's play/pause/seek drives everyone else's player automatically, + over a small backend that only ever coordinates timing — actual video + always streams directly from your Emby server to each viewer's browser. ## Status -Builds clean (0 errors, 0 warnings) against the real `MediaBrowser.Server.Core` -/ `MediaBrowser.Common` 4.9.1.90 assemblies from NuGet, and is installed on a -real Emby Server (Docker) — the plugin loads and its admin page shows up. -The actual feature (`partyPage.html`, reached via `EnableInUserMenu`) hasn't -been exercised live yet. See -[docs/architecture.md](docs/architecture.md#assumptions-to-verify) for -exactly what was wrong on the first guess, what was fixed, and what's still -open. - -## Requirements - -- Emby Server (developed against 4.9.5.0; should work on nearby 4.9.x/4.8.x - builds, but the plugin ABI does shift between versions) -- [.NET SDK](https://dotnet.microsoft.com/download) 8.0 to build (installed - per-user under `%USERPROFILE%\.dotnet` in this environment, since the - machine-wide winget install needed admin elevation that wasn't available — - see git history if you need to redo this on another machine) +Freshly scaffolded and smoke-tested (server starts, routes render, error +handling works — see git history) with a stubbed Emby server. **Not yet run +against a real Emby server.** The biggest open question is CORS: the browser +calls Emby directly from a different origin, which needs Emby (or its +reverse proxy) to answer that cross-origin requests are allowed — see +[docs/architecture.md](docs/architecture.md#assumptions-to-verify). ## Project layout ``` -src/WatchParty/ the plugin itself (C#, netstandard2.0) - Plugin.cs plugin registration + both plugin pages - Configuration/ plugin settings, the admin settings page, and - partyPage.html - the actual user-facing feature, - integrated into Emby's own menu (not a separate app) - Parties/ Party / PartyMember model + in-memory PartyManager - Sync/ the drift-correction background loop - Api/ REST endpoints, called from partyPage.html -docs/architecture.md full design doc +server/ Node.js backend: party/room coordination + WebSocket sync. + Never touches media bytes - only validates Emby tokens + (by asking Emby itself) and relays playback state. + src/index.js Express app + REST endpoints + src/wsHandler.js WebSocket protocol (see comments in the file) + src/partyManager.js in-memory room state + src/embyAuth.js token validation against your Emby server + +client/ Static frontend (plain JS, ES modules, no build step). + Talks directly to Emby for catalog/images/streaming, and + to server/ only for party coordination. + js/embyApi.js Emby REST calls (auth, search, images, stream URLs) + js/party.js our own backend's REST + WebSocket client + js/views/ one file per screen (login, browse, item, player) + +src/WatchParty/ The earlier Emby-plugin attempt. 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 ``` -## Building +## Running locally ```bash -dotnet build src/WatchParty/WatchParty.csproj +cd server +npm install +EMBY_SERVER_URL=https://your-emby-server npm start ``` -To have a build drop the DLL straight into a local Emby Server's plugin -folder, set `EMBY_PLUGINS_DIR` before building, e.g.: +Then open `http://localhost:3000`. + +## Deploying + +Build the Docker image from the **repo root** (it needs both `server/` and +`client/`): ```bash -EMBY_PLUGINS_DIR="C:/ProgramData/Emby-Server/programdata/plugins" dotnet build src/WatchParty/WatchParty.csproj +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 ``` -Adjust the path for however your server is installed (Windows service vs. -portable install vs. Docker volume). +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://:3000`. ## Roadmap -- [x] Design doc + scaffold -- [x] Build against the real SDK, fix whatever the `TODO(verify)` spots got wrong -- [x] Install on a real Emby Server (Docker) -- [x] Integrate into Emby's own UI instead of a standalone web app - (`EnableInUserMenu` — see [docs/architecture.md](docs/architecture.md#components)) +- [x] Design + scaffold the standalone app +- [x] Smoke-test locally (routing, error handling) without a real Emby server +- [ ] Verify against the real Emby server (CORS is the main risk - see above) - [ ] Try a full party end-to-end with two accounts -- [ ] Live (WebSocket) updates in the party page instead of polling -- [ ] Per-party override of sync settings instead of only server-wide defaults +- [ ] TV show navigation (seasons/episodes) beyond flat search +- [ ] Tune transcoding target / support direct play when possible -No chat/reactions — deliberately out of scope, not just deferred. +No chat, no OIDC — deliberately out of scope (Emby-only auth was the +explicit choice; see docs/architecture.md). ## License diff --git a/client/css/app.css b/client/css/app.css new file mode 100644 index 0000000..79bc1bb --- /dev/null +++ b/client/css/app.css @@ -0,0 +1,176 @@ +:root { + color-scheme: light dark; + --bg: #111418; + --panel: #1b1f26; + --text: #e8eaed; + --muted: #9aa4b2; + --accent: #5b8cff; + --danger: #ff6b6b; + --ok: #4cd97b; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; +} + +body { + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; +} + +#app { min-height: 100%; } + +.wrap { + max-width: 480px; + margin: 0 auto; + padding: 32px 20px 80px; +} + +.wrap.wide { max-width: 900px; } + +h1 { font-size: 22px; margin: 0; } +h2 { font-size: 16px; margin: 0 0 8px; } + +.topbar { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 20px; +} + +.panel { + background: var(--panel); + border-radius: 12px; + padding: 20px; +} + +.hint { color: var(--muted); font-size: 13px; margin: 0 0 16px; } +.error { color: var(--danger); font-size: 13px; min-height: 18px; } + +label { + display: block; + font-size: 13px; + color: var(--muted); + margin-bottom: 12px; +} + +input { + display: block; + width: 100%; + margin-top: 4px; + padding: 10px; + border-radius: 8px; + border: 1px solid #333a45; + background: #0e1116; + color: var(--text); + font-size: 14px; +} + +input[type="search"] { margin-bottom: 20px; } + +button { + padding: 12px 16px; + border: none; + border-radius: 8px; + background: var(--accent); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +button.linklike { + background: none; + color: var(--muted); + font-size: 12px; + font-weight: 400; + text-decoration: underline; + padding: 4px 0; +} + +.or { text-align: center; color: var(--muted); font-size: 12px; margin: 16px 0; } +.code { color: var(--accent); letter-spacing: .1em; } + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 16px; +} + +.card { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text); + text-decoration: none; + font-size: 13px; +} + +.card img { + width: 100%; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 8px; + background: var(--panel); +} + +.detail { + display: flex; + gap: 20px; + margin-top: 16px; +} + +.detail img { + width: 160px; + border-radius: 8px; + flex-shrink: 0; +} + +.detail button { width: 100%; margin-top: 8px; } + +.playerWrap { + position: relative; + width: 100%; + height: 100vh; + background: #000; +} + +.playerWrap video { + width: 100%; + height: 100%; + object-fit: contain; +} + +.overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent); + color: white; + font-size: 13px; +} + +.overlay .members { color: var(--muted); flex: 1; } + +.followerNotice { + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + background: rgba(0,0,0,.6); + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + color: var(--muted); +} + +.hide { display: none; } diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..c31f550 --- /dev/null +++ b/client/index.html @@ -0,0 +1,14 @@ + + + + + + Watch Party + + + + +
+ + + diff --git a/client/js/app.js b/client/js/app.js new file mode 100644 index 0000000..17398ec --- /dev/null +++ b/client/js/app.js @@ -0,0 +1,24 @@ +import { route, start, navigate } from './router.js'; +import { session } from './embyApi.js'; +import { renderLogin } from './views/login.js'; +import { renderBrowse } from './views/browse.js'; +import { renderItemDetail } from './views/itemDetail.js'; +import { renderPartyPlayer } from './views/player.js'; + +function requireAuth(renderFn) { + return (root, params) => { + if (!session.isLoggedIn) { + navigate('/login'); + return; + } + renderFn(root, params); + }; +} + +route('/', () => navigate(session.isLoggedIn ? '/browse' : '/login')); +route('/login', renderLogin); +route('/browse', requireAuth(renderBrowse)); +route('/item/:id', requireAuth(renderItemDetail)); +route('/party/:id', requireAuth(renderPartyPlayer)); + +start(document.getElementById('app')); diff --git a/client/js/embyApi.js b/client/js/embyApi.js new file mode 100644 index 0000000..ba02f23 --- /dev/null +++ b/client/js/embyApi.js @@ -0,0 +1,110 @@ +// Direct browser <-> Emby calls: catalog, images, streaming. Session/token +// lives in localStorage, scoped to this browser only. Nothing here ever +// talks to our own backend - see party.js for that. +// +// TODO(verify): these calls go cross-origin (this app's domain -> Emby's +// domain), which needs Emby (or its reverse proxy) to answer CORS preflight +// requests. If login/search silently fail with a network error and the +// browser console shows a CORS complaint, this whole approach needs a proxy +// through our own backend instead - see docs/architecture.md. + +function authHeaderValue() { + let deviceId = localStorage.getItem('wp_deviceId'); + if (!deviceId) { + deviceId = 'watchparty-' + crypto.randomUUID(); + localStorage.setItem('wp_deviceId', deviceId); + } + return `MediaBrowser Client="WatchParty", Device="Browser", DeviceId="${deviceId}", Version="0.1.0"`; +} + +export const session = { + get serverUrl() { return localStorage.getItem('wp_serverUrl') || ''; }, + get token() { return localStorage.getItem('wp_token') || ''; }, + get userId() { return localStorage.getItem('wp_userId') || ''; }, + get userName() { return localStorage.getItem('wp_userName') || ''; }, + get isLoggedIn() { return !!(this.serverUrl && this.token && this.userId); }, + set(serverUrl, token, userId, userName) { + localStorage.setItem('wp_serverUrl', serverUrl); + localStorage.setItem('wp_token', token); + localStorage.setItem('wp_userId', userId); + localStorage.setItem('wp_userName', userName); + }, + clear() { + ['wp_serverUrl', 'wp_token', 'wp_userId', 'wp_userName'].forEach((k) => localStorage.removeItem(k)); + } +}; + +async function embyFetch(path, options = {}) { + const res = await fetch(session.serverUrl.replace(/\/+$/, '') + path, { + ...options, + headers: { + 'X-Emby-Authorization': authHeaderValue(), + ...(session.token ? { 'X-Emby-Token': session.token } : {}), + ...(options.headers || {}) + } + }); + if (!res.ok) { + throw new Error(`Emby request failed: ${res.status}`); + } + const text = await res.text(); + return text ? JSON.parse(text) : null; +} + +export async function login(serverUrl, username, password) { + const cleanUrl = serverUrl.trim().replace(/\/+$/, ''); + const res = await fetch(cleanUrl + '/Users/AuthenticateByName', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Emby-Authorization': authHeaderValue() + }, + body: JSON.stringify({ Username: username, Pw: password }) + }); + if (!res.ok) { + throw new Error('Identifiants refusés.'); + } + const data = await res.json(); + session.set(cleanUrl, data.AccessToken, data.User.Id, data.User.Name); + return data; +} + +export async function searchItems(query) { + if (!query || query.trim().length < 2) { + return []; + } + const params = new URLSearchParams({ + searchTerm: query, + IncludeItemTypes: 'Movie,Episode,Series', + Recursive: 'true', + Fields: 'Overview,ProductionYear', + ImageTypeLimit: '1', + Limit: '24' + }); + const data = await embyFetch(`/Users/${session.userId}/Items?${params.toString()}`); + return (data && data.Items) || []; +} + +export async function getItem(itemId) { + return embyFetch(`/Users/${session.userId}/Items/${itemId}`); +} + +export function imageUrl(itemId, maxWidth = 300) { + // can't send custom headers, so the token has to ride along in the + // query string here - the one deliberate exception to keeping tokens out + // of URLs. This is the same thing Emby's own web client does for images. + const params = new URLSearchParams({ api_key: session.token, maxWidth: String(maxWidth) }); + return `${session.serverUrl}/Items/${itemId}/Images/Primary?${params.toString()}`; +} + +export function streamUrl(itemId) { + // Same deliberate exception as imageUrl: