From 4a31ee741e111fad9e970cf2e1f6a20cc0e91832 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 14 Sep 2026 09:38:14 +0200 Subject: [PATCH] Rewrite server + client in TypeScript, service-oriented server architecture 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//.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 --- .dockerignore | 8 + .gitignore | 8 +- README.md | 107 ++- client/build.mjs | 47 + client/css/app.css | 812 ------------------ client/js/app.js | 41 - client/js/embyApi.js | 118 --- client/js/party.js | 124 --- client/js/router.js | 76 -- client/js/shell.js | 61 -- client/js/views/album.js | 68 -- client/js/views/artist.js | 35 - client/js/views/login.js | 33 - client/js/views/series.js | 100 --- client/package-lock.json | 478 +++++++++++ client/package.json | 14 + client/src/api/embyApi.ts | 123 +++ client/src/api/party.ts | 145 ++++ client/src/app.ts | 43 + client/src/global.d.ts | 59 ++ client/{ => src}/index.html | 4 +- client/src/router.ts | 105 +++ client/src/shell/Shell.css | 81 ++ client/src/shell/Shell.html | 12 + client/src/shell/Shell.ts | 46 + client/src/styles/base.css | 122 +++ client/src/styles/detail.css | 21 + client/src/styles/presence.css | 25 + client/src/styles/trackList.css | 29 + client/src/tiles.css | 118 +++ client/{js/tiles.js => src/tiles.ts} | 43 +- client/src/types.ts | 140 +++ client/src/views/album/Album.css | 21 + client/src/views/album/Album.html | 10 + client/src/views/album/Album.ts | 72 ++ client/src/views/artist/Artist.html | 3 + client/src/views/artist/Artist.ts | 34 + client/src/views/home/Home.css | 44 + client/src/views/home/Home.html | 10 + .../views/home.js => src/views/home/Home.ts} | 70 +- client/src/views/library/Library.html | 2 + .../views/library/Library.ts} | 27 +- client/src/views/login/Login.html | 14 + client/src/views/login/Login.ts | 22 + client/src/views/musicPlayer/MusicPlayer.css | 61 ++ client/src/views/musicPlayer/MusicPlayer.html | 28 + .../views/musicPlayer/MusicPlayer.ts} | 170 ++-- client/src/views/player/Player.css | 195 +++++ client/src/views/player/Player.html | 34 + .../player.js => src/views/player/Player.ts} | 172 ++-- client/src/views/series/Series.css | 91 ++ client/src/views/series/Series.html | 11 + client/src/views/series/Series.ts | 103 +++ client/tsconfig.json | 16 + docs/architecture.md | 237 ++++- server/Dockerfile | 32 +- server/package-lock.json | 662 +++++++++++++- server/package.json | 14 +- server/src/Logger.ts | 44 + server/src/config.ts | 19 + server/src/emby/EmbyClient.ts | 88 ++ server/src/embyAuth.js | 108 --- server/src/embyProxy.js | 79 -- server/src/index.js | 623 -------------- server/src/index.ts | 344 ++++++++ server/src/logger.js | 39 - server/src/musicQueueService.js | 228 ----- server/src/partyManager.js | 196 ----- server/src/realtime/SocketGateway.ts | 229 +++++ server/src/services/AuthService.ts | 101 +++ server/src/services/CatalogService.ts | 385 +++++++++ server/src/services/MusicQueueService.ts | 219 +++++ server/src/services/PartyService.ts | 211 +++++ server/src/socketHandler.js | 227 ----- server/src/types.ts | 294 +++++++ server/tsconfig.json | 18 + 76 files changed, 5567 insertions(+), 3286 deletions(-) create mode 100644 .dockerignore create mode 100644 client/build.mjs delete mode 100644 client/css/app.css delete mode 100644 client/js/app.js delete mode 100644 client/js/embyApi.js delete mode 100644 client/js/party.js delete mode 100644 client/js/router.js delete mode 100644 client/js/shell.js delete mode 100644 client/js/views/album.js delete mode 100644 client/js/views/artist.js delete mode 100644 client/js/views/login.js delete mode 100644 client/js/views/series.js create mode 100644 client/package-lock.json create mode 100644 client/package.json create mode 100644 client/src/api/embyApi.ts create mode 100644 client/src/api/party.ts create mode 100644 client/src/app.ts create mode 100644 client/src/global.d.ts rename client/{ => src}/index.html (80%) create mode 100644 client/src/router.ts create mode 100644 client/src/shell/Shell.css create mode 100644 client/src/shell/Shell.html create mode 100644 client/src/shell/Shell.ts create mode 100644 client/src/styles/base.css create mode 100644 client/src/styles/detail.css create mode 100644 client/src/styles/presence.css create mode 100644 client/src/styles/trackList.css create mode 100644 client/src/tiles.css rename client/{js/tiles.js => src/tiles.ts} (68%) create mode 100644 client/src/types.ts create mode 100644 client/src/views/album/Album.css create mode 100644 client/src/views/album/Album.html create mode 100644 client/src/views/album/Album.ts create mode 100644 client/src/views/artist/Artist.html create mode 100644 client/src/views/artist/Artist.ts create mode 100644 client/src/views/home/Home.css create mode 100644 client/src/views/home/Home.html rename client/{js/views/home.js => src/views/home/Home.ts} (62%) create mode 100644 client/src/views/library/Library.html rename client/{js/views/library.js => src/views/library/Library.ts} (69%) create mode 100644 client/src/views/login/Login.html create mode 100644 client/src/views/login/Login.ts create mode 100644 client/src/views/musicPlayer/MusicPlayer.css create mode 100644 client/src/views/musicPlayer/MusicPlayer.html rename client/{js/views/musicPlayer.js => src/views/musicPlayer/MusicPlayer.ts} (70%) create mode 100644 client/src/views/player/Player.css create mode 100644 client/src/views/player/Player.html rename client/{js/views/player.js => src/views/player/Player.ts} (70%) create mode 100644 client/src/views/series/Series.css create mode 100644 client/src/views/series/Series.html create mode 100644 client/src/views/series/Series.ts create mode 100644 client/tsconfig.json create mode 100644 server/src/Logger.ts create mode 100644 server/src/config.ts create mode 100644 server/src/emby/EmbyClient.ts delete mode 100644 server/src/embyAuth.js delete mode 100644 server/src/embyProxy.js delete mode 100644 server/src/index.js create mode 100644 server/src/index.ts delete mode 100644 server/src/logger.js delete mode 100644 server/src/musicQueueService.js delete mode 100644 server/src/partyManager.js create mode 100644 server/src/realtime/SocketGateway.ts create mode 100644 server/src/services/AuthService.ts create mode 100644 server/src/services/CatalogService.ts create mode 100644 server/src/services/MusicQueueService.ts create mode 100644 server/src/services/PartyService.ts delete mode 100644 server/src/socketHandler.js create mode 100644 server/src/types.ts create mode 100644 server/tsconfig.json diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..663a414 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Never actually COPYed into the image (server/Dockerfile copies specific +# paths, not the whole tree) - excluded anyway so `docker build` doesn't +# waste time uploading them as part of the build context. +**/node_modules/ +server/dist/ +client/dist/ +.git/ +.claude/ diff --git a/.gitignore b/.gitignore index a68c909..42855a3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,11 +10,17 @@ obj/ *.swp .claude/ -## Node (server/) +## Node (server/, client/) node_modules/ .env .env.local +## TypeScript/esbuild build output - server/Dockerfile builds these itself +## (multi-stage), so they're never committed - see docs/architecture.md. +server/dist/ +client/dist/ +*.tsbuildinfo + ## OS cruft .DS_Store Thumbs.db diff --git a/README.md b/README.md index 89e0b89..43ced21 100644 --- a/README.md +++ b/README.md @@ -58,53 +58,103 @@ 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 compiled server passes the same +unit/integration tests the pre-rewrite JS did (run against the compiled +output, not the source), and the compiled+bundled client was driven +through every page and both players end to end (mocked network/Socket.IO) +in a real browser. **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 backend: proxies ALL Emby access (auth, catalog, - images, HLS streaming) and coordinates party/room state + - realtime sync. The browser only ever talks to this. - src/index.js Express app + REST endpoints - src/embyAuth.js server-side login against Emby + session cookies - src/embyProxy.js generic Emby request/response proxying (incl. HLS) - src/socketHandler.js Socket.IO protocol (see comments in the file) - src/partyManager.js in-memory room state - src/musicQueueService.js participative music queue - see docs/architecture.md - src/logger.js party lifecycle logging (docker logs/journalctl-friendly) +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/ Static frontend (plain JS, ES modules, no build step). - Talks only to server/ - never to Emby directly. - js/shell.js persistent sidebar + #mainContent the router swaps - js/tiles.js poster-tile rendering shared by home.js and library.js - js/embyApi.js calls our own /api/* routes (auth, catalog, playback options) - js/party.js our own backend's party REST + Socket.IO client - js/views/ one file per screen: login, home, library, series, player, - artist, album, musicPlayer +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/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 + src/views/player/Player.* the /party/:id route's dispatcher + the video player + src/views/musicPlayer/MusicPlayer.* the music half - now-playing, queue, inline browse+add -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. +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 server -npm install +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. + ## Deploying Build the Docker image from the **repo root** (it needs both `server/` and -`client/`): +`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 . @@ -131,6 +181,9 @@ Then put it behind your existing reverse proxy like everything else 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 - [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Tune transcoding target / support direct play when possible diff --git a/client/build.mjs b/client/build.mjs new file mode 100644 index 0000000..0290eef --- /dev/null +++ b/client/build.mjs @@ -0,0 +1,47 @@ +// Client build: TypeScript (+ per-component .html/.css) -> client/dist, +// via esbuild. Kept as a small hand-written script rather than a config +// file for a heavier tool (Vite, webpack) - this app's whole client is a +// couple thousand lines across a dozen files, esbuild's own bundling +// (including its built-in "collect CSS imported from JS into a sibling +// .css file" behavior) is all it needs, consistent with this project's +// general bias against dependencies it doesn't need. +// +// Usage: node build.mjs [--watch] +// npm run build -> one-off build (what server/Dockerfile's build stage runs) +// npm run watch -> rebuilds on save, for local dev (see README's +// Running locally section) +import * as esbuild from 'esbuild'; +import { copyFileSync, mkdirSync } from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const outdir = path.join(here, 'dist'); + +mkdirSync(outdir, { recursive: true }); +copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html')); + +const buildOptions = { + entryPoints: [path.join(here, 'src', 'app.ts')], + outfile: path.join(outdir, 'app.js'), + bundle: true, + format: 'esm', + target: 'es2020', + sourcemap: true, + // Each component imports its own template as a raw string (`import html + // from './Foo.html'`) - see global.d.ts. CSS imports need no loader + // entry: esbuild bundles any CSS reached from a JS/TS entry point into a + // sibling app.css automatically. + loader: { '.html': 'text' } +}; + +const watch = process.argv.includes('--watch'); + +if (watch) { + const ctx = await esbuild.context(buildOptions); + await ctx.watch(); + console.log('[watchparty-client] watching for changes...'); +} else { + await esbuild.build(buildOptions); + console.log('[watchparty-client] built to client/dist'); +} diff --git a/client/css/app.css b/client/css/app.css deleted file mode 100644 index 62b600c..0000000 --- a/client/css/app.css +++ /dev/null @@ -1,812 +0,0 @@ -/* Colors pulled live from this Emby server's own active theme (dark), via - its CSS custom properties (--theme-primary-color-*, --background-*, etc.) - - see docs/architecture.md for how these were read directly off the - running server rather than guessed. */ -:root { - color-scheme: dark; - --bg: #151216; /* hsl(285, 10%, 7.84%) - Emby's dark theme background */ - --bg-header: #221d24; /* same hue, +5% lightness - Emby's header/footer background */ - --panel: #262626; /* Emby's card/button/input background (achromatic, ~15-17% lightness) */ - --text: #ffffff; - --muted: rgba(255, 255, 255, .6); - --accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */ - --accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */ - --danger: #ff6b6b; -} - -* { 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%; } - -/* Login is a small centered form - everything else lives inside the app - shell (sidebar + .mainContent), see below. */ -.wrap { - max-width: 420px; - margin: 0 auto; - padding: 32px 20px 80px; -} - -h1 { font-size: 22px; margin: 0; } -h2 { font-size: 16px; margin: 0 0 12px; } - -.panel { - background: var(--bg-header); - 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 #3a3a3a; - background: var(--panel); - color: var(--text); - font-size: 14px; -} - -input:focus { - outline: none; - border-color: var(--accent); -} - -button { - padding: 12px 16px; - border: none; - border-radius: 8px; - background: var(--accent); - color: white; - font-size: 14px; - font-weight: 600; - cursor: pointer; -} - -button:hover { background: var(--accent-bright); } -button[disabled] { opacity: .5; cursor: default; } - -/* Class-only (not tag-qualified) so an - e.g. the - "Voir sur Emby" link on the series detail page - gets the same treatment - as the + +
+ diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts new file mode 100644 index 0000000..c7b8966 --- /dev/null +++ b/client/src/shell/Shell.ts @@ -0,0 +1,46 @@ +// The persistent app chrome (sidebar with collections + main content +// area), rendered once per login. The router (router.ts) only ever swaps +// what's inside #mainContent - see app.ts's boot(). +import './Shell.css'; +import html from './Shell.html'; +import { auth, getLibraries, logout } from '../api/embyApi'; + +export async function renderShell(appEl: HTMLElement): Promise { + appEl.innerHTML = html; + appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`; + + appEl.querySelector('#logout')!.addEventListener('click', async () => { + await logout(); + // Simplest reliable way back to a bare login screen: a real reload, + // rather than hand-tearing-down every shell/router listener in place. + location.hash = ''; + location.reload(); + }); + + const nav = appEl.querySelector('#sidebarNav')!; + const status = appEl.querySelector('#libStatus')!; + try { + const libraries = await getLibraries(); + status.remove(); + libraries.forEach((lib) => { + const a = document.createElement('a'); + a.href = `#/library/${lib.id}`; + a.className = 'navItem'; + a.textContent = lib.name; + nav.appendChild(a); + }); + } catch { + status.textContent = 'Bibliothèques indisponibles.'; + } + + const highlight = () => { + nav.querySelectorAll('.navItem').forEach((a) => { + const isHome = a.getAttribute('href') === '#/home'; + a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash)); + }); + }; + highlight(); + window.addEventListener('hashchange', highlight); + + return appEl.querySelector('#mainContent') as HTMLElement; +} diff --git a/client/src/styles/base.css b/client/src/styles/base.css new file mode 100644 index 0000000..66c82c8 --- /dev/null +++ b/client/src/styles/base.css @@ -0,0 +1,122 @@ +/* Colors pulled live from this Emby server's own active theme (dark), via + its CSS custom properties (--theme-primary-color-*, --background-*, etc.) + - see docs/architecture.md for how these were read directly off the + running server rather than guessed. Loaded once, globally, by app.ts - + every other component/*.css only ever reads these variables, never + redefines them. */ +:root { + color-scheme: dark; + --bg: #151216; /* hsl(285, 10%, 7.84%) - Emby's dark theme background */ + --bg-header: #221d24; /* same hue, +5% lightness - Emby's header/footer background */ + --panel: #262626; /* Emby's card/button/input background (achromatic, ~15-17% lightness) */ + --text: #ffffff; + --muted: rgba(255, 255, 255, .6); + --accent: #52b54b; /* Emby's theme-primary-color (hsl(116, 41.7%, 50.2%)) */ + --accent-bright: #6ccf65; /* Emby's --theme-accent-text-color-darkbg */ + --danger: #ff6b6b; +} + +* { 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%; } + +/* Login is a small centered form - everything else lives inside the app + shell (sidebar + .mainContent) - see shell/Shell.css. */ +.wrap { + max-width: 420px; + margin: 0 auto; + padding: 32px 20px 80px; +} + +h1 { font-size: 22px; margin: 0; } +h2 { font-size: 16px; margin: 0 0 12px; } + +.panel { + background: var(--bg-header); + 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 #3a3a3a; + background: var(--panel); + color: var(--text); + font-size: 14px; +} + +input:focus { + outline: none; + border-color: var(--accent); +} + +button { + padding: 12px 16px; + border: none; + border-radius: 8px; + background: var(--accent); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +button:hover { background: var(--accent-bright); } +button[disabled] { opacity: .5; cursor: default; } + +/* Class-only (not tag-qualified) so an - e.g. the + "Voir sur Emby" link on the series/album detail pages - gets the same + treatment as the ` : ''} - ${drillHref ? `${drill.label}` : ''} + ${drillHref ? `${drill!.label}` : ''} Voir sur Emby ${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''} @@ -64,20 +67,20 @@ export function tileHtml(item) { /** * Wires up every "Lancer une watch party" button inside `container`. - * `onStart(itemId)` does the actual work (create the party + navigate) - kept - * as a caller-supplied callback so this module doesn't need to depend on - * party.js/router.js itself. + * `onStart(itemId)` does the actual work (create the party + navigate) - + * kept as a caller-supplied callback so this module doesn't need to + * depend on api/party.ts/router.ts itself. */ -export function wireTileButtons(container, onStart) { - container.querySelectorAll('.startPartyBtn').forEach((btn) => { +export function wireTileButtons(container: ParentNode, onStart: (itemId: string) => Promise): void { + container.querySelectorAll('.startPartyBtn').forEach((btn) => { btn.addEventListener('click', async (e) => { e.preventDefault(); btn.disabled = true; btn.textContent = 'Démarrage...'; try { - await onStart(btn.dataset.itemId); + await onStart(btn.dataset.itemId as string); } catch (err) { - alert(err.message); + alert((err as Error).message); btn.disabled = false; btn.textContent = 'Lancer une watch party'; } diff --git a/client/src/types.ts b/client/src/types.ts new file mode 100644 index 0000000..5521d43 --- /dev/null +++ b/client/src/types.ts @@ -0,0 +1,140 @@ +/** + * Shared client-side types - overlapping copies of server/src/types.ts's + * DTO shapes rather than a real shared package (the two projects compile + * independently - tsc for the server, esbuild for the client - and a + * shared package would be a bigger structural commitment than this app's + * size justifies). See docs/architecture.md. + */ + +export interface ItemDto { + id: string; + name: string; + overview?: string; + year?: number; + type?: string; + canWatchParty: boolean; + imageUrl: string; + embyUrl: string; + durationTicks: number | null; + indexNumber: number | null; + artistName: string | null; + albumName: string | null; +} + +export interface LibraryDto { + id: string; + name: string; + collectionType: string; +} + +export interface SeasonDto { + id: string; + name: string; + indexNumber?: number; +} + +export interface PlaybackTrackOption { + index: number; + label: string; +} + +export interface PlaybackOptionsDto { + audioTracks: PlaybackTrackOption[]; + subtitleTracks: PlaybackTrackOption[]; +} + +export type MediaType = 'video' | 'music'; + +export interface PublicMember { + userId: string; + userName: string; + isHost: boolean; + isPaused: boolean; + positionTicks: number; + connected: boolean; +} + +export interface MusicTrack { + itemId: string; + itemName: string; + artistName: string | null; + albumName: string | null; + itemImageUrl: string; + durationSeconds: number | null; + addedByUserId: string; + addedByUserName: string; +} + +/** What this app's own catalog DTO (ItemDto) looks like once reshaped into + * a track add request - see tiles/album/musicPlayer's addTrackToQueue calls. */ +export interface MusicTrackInput { + itemId: string; + itemName: string; + artistName?: string | null; + albumName?: string | null; + itemImageUrl: string; + durationTicks?: number | null; +} + +export interface MusicCurrentPublic extends MusicTrack { + isPaused: boolean; + elapsedSeconds: number; +} + +export interface MusicPublicState { + queue: MusicTrack[]; + current: MusicCurrentPublic | null; + skipVotes: number; +} + +export interface PartyPublicState { + partyId: string; + mediaType: MediaType; + itemId: string; + itemName: string; + itemImageUrl: string; + createdUtc: number; + hasNext: boolean; + queueLength: number; + queueIndex: number; + hostUserName: string | null; + memberCount: number; + youAreHost: boolean; + members: PublicMember[]; + music: MusicPublicState | null; +} + +/** The socket "members" broadcast's shape - smaller than + * PartyPublicState.members above (no isPaused/positionTicks) - see + * server/src/types.ts's SocketMemberDto for why. */ +export interface SocketMember { + userId: string; + userName: string; + isHost: boolean; + connected: boolean; +} + +export interface LobbyPartyDto { + partyId: string; + mediaType: MediaType; + itemId: string; + itemName: string; + itemImageUrl: string; + hostUserName: string | null; + memberCount: number; + createdUtc: number; +} + +export interface ItemChangedMsg { + itemId: string; + itemName: string; + itemImageUrl: string; + queueIndex: number; + queueLength: number; + hasNext: boolean; +} + +export interface PauseRequestedMsg { + userId: string; + userName: string; +} diff --git a/client/src/views/album/Album.css b/client/src/views/album/Album.css new file mode 100644 index 0000000..2c80060 --- /dev/null +++ b/client/src/views/album/Album.css @@ -0,0 +1,21 @@ +.albumHeader { + display: flex; + gap: 20px; + margin-bottom: 24px; +} + +.albumCover { + width: 160px; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 8px; + background: var(--panel); + flex-shrink: 0; +} + +.albumCover.noImage { visibility: hidden; } + +@media (max-width: 600px) { + .albumHeader { flex-direction: column; } + .albumCover { width: 120px; } +} diff --git a/client/src/views/album/Album.html b/client/src/views/album/Album.html new file mode 100644 index 0000000..5fa44f0 --- /dev/null +++ b/client/src/views/album/Album.html @@ -0,0 +1,10 @@ + +
+ +
+

+

+ Voir sur Emby +
+
+
diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts new file mode 100644 index 0000000..d21bdaa --- /dev/null +++ b/client/src/views/album/Album.ts @@ -0,0 +1,72 @@ +import './Album.css'; +import '../../styles/trackList.css'; +import html from './Album.html'; +import { getAlbumTracks, getItem } from '../../api/embyApi'; +import { createParty } from '../../api/party'; +import { navigate } from '../../router'; +import type { RouteParams } from '../../router'; +import { escapeHtml, formatDuration } from '../../tiles'; +import type { ItemDto } from '../../types'; + +const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions + +/** Route `/album/:id` - the last level of the music hierarchy: one album's + * tracks, each startable as its own listen party (mirrors views/series's + * episode list, but flat - an album has no further sub-level the way a + * series' seasons do). */ +export async function renderAlbum(root: HTMLElement, params: RouteParams): Promise { + const albumId = params.id as string; + root.innerHTML = '

Chargement...

'; + + let album: ItemDto; + let tracks: ItemDto[]; + try { + [album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]); + } catch { + root.innerHTML = '

Impossible de charger cet album.

'; + return; + } + + root.innerHTML = html; + root.querySelector('#back')!.addEventListener('click', () => history.back()); + (root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl; + root.querySelector('#albumTitle')!.innerHTML = album.year + ? `${escapeHtml(album.name)} (${album.year})` + : escapeHtml(album.name); + if (album.artistName) { + const artistEl = root.querySelector('#albumArtist')!; + artistEl.textContent = album.artistName; + artistEl.classList.remove('hide'); + } + (root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl; + + const listEl = root.querySelector('#trackList')!; + if (!tracks.length) { + listEl.innerHTML = '

Aucun morceau dans cet album.

'; + return; + } + + listEl.innerHTML = tracks.map((t) => ` +
+ ${t.indexNumber != null ? t.indexNumber : ''} + ${escapeHtml(t.name)} + ${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''} + +
+ `).join(''); + + listEl.querySelectorAll('.startPartyBtn').forEach((btn) => { + btn.addEventListener('click', async () => { + btn.disabled = true; + btn.textContent = 'Démarrage...'; + try { + const party = await createParty(btn.dataset.itemId as string); + navigate(`/party/${party.partyId}`); + } catch (err) { + alert((err as Error).message); + btn.disabled = false; + btn.textContent = 'Écouter ensemble'; + } + }); + }); +} diff --git a/client/src/views/artist/Artist.html b/client/src/views/artist/Artist.html new file mode 100644 index 0000000..b5a91d1 --- /dev/null +++ b/client/src/views/artist/Artist.html @@ -0,0 +1,3 @@ + + +
diff --git a/client/src/views/artist/Artist.ts b/client/src/views/artist/Artist.ts new file mode 100644 index 0000000..8dc88c5 --- /dev/null +++ b/client/src/views/artist/Artist.ts @@ -0,0 +1,34 @@ +import html from './Artist.html'; +import { getArtistAlbums, getItem } from '../../api/embyApi'; +import type { RouteParams } from '../../router'; +import { tileHtml } from '../../tiles'; + +/** Route `/artist/:id` - one artist's albums, reached from a music + * library's top-level grid (see views/library) via a MusicArtist tile's + * "Voir les albums" link (tiles.ts's DRILL_DOWN table). Structurally the + * same "detail header + grid" shape as views/series, just one level up + * the music hierarchy (Artist -> Album, not Series -> Season/Episode) and + * with no per-item action beyond drilling further into an album (an + * artist has nothing itself to start a party from). */ +export async function renderArtist(root: HTMLElement, params: RouteParams): Promise { + const artistId = params.id as string; + root.innerHTML = '

Chargement...

'; + + let artist; + let albums; + try { + [artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]); + } catch { + root.innerHTML = '

Impossible de charger cet artiste.

'; + return; + } + + root.innerHTML = html; + root.querySelector('h1')!.textContent = artist.name; + root.querySelector('#back')!.addEventListener('click', () => history.back()); + + const grid = root.querySelector('#grid')!; + grid.innerHTML = albums.length + ? albums.map(tileHtml).join('') + : '

Aucun album trouvé pour cet artiste.

'; +} diff --git a/client/src/views/home/Home.css b/client/src/views/home/Home.css new file mode 100644 index 0000000..1becd23 --- /dev/null +++ b/client/src/views/home/Home.css @@ -0,0 +1,44 @@ +.section { margin-bottom: 32px; } + +/* ---- Horizontal carousels ---- */ + +.carousel { + display: flex; + gap: 16px; + overflow-x: auto; + padding-bottom: 10px; +} + +.carousel .tile { flex: 0 0 160px; } +.carousel .partyCard { flex: 0 0 280px; } + +.partyCard { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-header); + border-radius: 10px; + padding: 10px; +} + +.partyCard img { + width: 48px; + height: 72px; + object-fit: cover; + border-radius: 6px; + background: var(--panel); + flex-shrink: 0; +} + +.partyCard img.noImage { visibility: hidden; } + +.partyCardInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; } diff --git a/client/src/views/home/Home.html b/client/src/views/home/Home.html new file mode 100644 index 0000000..a4f6f9c --- /dev/null +++ b/client/src/views/home/Home.html @@ -0,0 +1,10 @@ + + + + +
diff --git a/client/js/views/home.js b/client/src/views/home/Home.ts similarity index 62% rename from client/js/views/home.js rename to client/src/views/home/Home.ts index fc46515..ae2169b 100644 --- a/client/js/views/home.js +++ b/client/src/views/home/Home.ts @@ -1,42 +1,36 @@ -import { getLibraries, getRecentItems } from '../embyApi.js'; -import { connectLobby, joinParty, createParty } from '../party.js'; -import { navigate } from '../router.js'; -import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js'; +import './Home.css'; +import html from './Home.html'; +import { getLibraries, getRecentItems } from '../../api/embyApi'; +import { connectLobby, createParty, joinParty } from '../../api/party'; +import { navigate } from '../../router'; +import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; +import type { LobbyPartyDto } from '../../types'; -export function renderHome(root) { - root.innerHTML = ` - - - - -
`; +export function renderHome(root: HTMLElement): () => void { + root.innerHTML = html; // Live, not a one-shot fetch: the list (and each card's host/member count) // updates the moment anything changes anywhere - see - // server/src/socketHandler.js's "lobby" room. The elapsed-time label is - // the one piece that ticks purely client-side (from createdUtc) - no - // point re-pushing from the server once a second just for a clock. + // server/src/realtime/SocketGateway.ts's "lobby" room. The elapsed-time + // label is the one piece that ticks purely client-side (from + // createdUtc) - no point re-pushing from the server once a second just + // for a clock. const lobby = connectLobby((parties) => renderParties(root, parties)); - const elapsedTimer = setInterval(() => updateElapsedLabels(root), 1000); + const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000); loadRecentSections(root); - // Called by router.js before rendering whatever comes next - stops the - // lobby socket and the ticking clock from running after you've navigated - // away (see docs/architecture.md's memory-leak writeup). + // Called by router.ts before rendering whatever comes next - stops the + // lobby socket and the ticking clock from running after you've + // navigated away (see docs/architecture.md's memory-leak writeup). return function cleanup() { lobby.disconnect(); clearInterval(elapsedTimer); }; } -function renderParties(root, parties) { - const section = root.querySelector('#partiesSection'); +function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void { + const section = root.querySelector('#partiesSection') as HTMLElement | null; const list = root.querySelector('#partiesCarousel'); if (!section || !list) return; // navigated away mid-flight @@ -61,17 +55,17 @@ function renderParties(root, parties) { `).join(''); - list.querySelectorAll('.joinPartyBtn').forEach((btn) => { + list.querySelectorAll('.joinPartyBtn').forEach((btn) => { btn.addEventListener('click', async () => { btn.disabled = true; try { // Actually register as a member before navigating - the player's - // realtime "join" is rejected server-side otherwise (you'd load the - // video but never get added to the members list or sync). - await joinParty(btn.dataset.partyId); + // realtime "join" is rejected server-side otherwise (you'd load + // the video but never get added to the members list or sync). + await joinParty(btn.dataset.partyId as string); navigate(`/party/${btn.dataset.partyId}`); } catch (err) { - alert(err.message); + alert((err as Error).message); btn.disabled = false; } }); @@ -80,30 +74,30 @@ function renderParties(root, parties) { updateElapsedLabels(root); } -function updateElapsedLabels(root) { +function updateElapsedLabels(root: HTMLElement): void { const now = Date.now(); - root.querySelectorAll('.elapsed').forEach((el) => { + root.querySelectorAll('.elapsed').forEach((el) => { const created = Number(el.dataset.created); if (!created) return; el.textContent = formatElapsed(now - created); }); } -function formatElapsed(ms) { +function formatElapsed(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } -async function loadRecentSections(root) { - const container = root.querySelector('#recentSections'); +async function loadRecentSections(root: HTMLElement): Promise { + const container = root.querySelector('#recentSections') as HTMLElement; let libraries; try { libraries = await getLibraries(); - } catch (err) { + } catch { container.innerHTML = '

Impossible de charger les bibliothèques Emby.

'; return; } @@ -120,7 +114,7 @@ async function loadRecentSections(root) { section.remove(); return; } - const carousel = section.querySelector('.carousel'); + const carousel = section.querySelector('.carousel')!; carousel.innerHTML = items.map(tileHtml).join(''); wireTileButtons(carousel, async (itemId) => { const party = await createParty(itemId); diff --git a/client/src/views/library/Library.html b/client/src/views/library/Library.html new file mode 100644 index 0000000..7312e2e --- /dev/null +++ b/client/src/views/library/Library.html @@ -0,0 +1,2 @@ + +

Chargement...

diff --git a/client/js/views/library.js b/client/src/views/library/Library.ts similarity index 69% rename from client/js/views/library.js rename to client/src/views/library/Library.ts index 70e5301..b10d6aa 100644 --- a/client/js/views/library.js +++ b/client/src/views/library/Library.ts @@ -1,7 +1,9 @@ -import { getLibraries, getLibraryItems, getArtists } from '../embyApi.js'; -import { createParty } from '../party.js'; -import { navigate } from '../router.js'; -import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js'; +import html from './Library.html'; +import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi'; +import { createParty } from '../../api/party'; +import { navigate } from '../../router'; +import type { RouteParams } from '../../router'; +import { tileHtml, wireTileButtons } from '../../tiles'; /** * Generic "one library" page, reached from the sidebar. What it renders @@ -9,19 +11,19 @@ import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js'; * - movies/tvshows/musicvideos (or anything else playable): a tile grid, * same as the home page's carousels (Series/MusicArtist/MusicAlbum * tiles drill further in instead of starting a party directly - see - * tiles.js's DRILL_DOWN table). + * tiles.ts's DRILL_DOWN table). * - music: a tile grid too, just of artists (Emby's dedicated /Artists * endpoint, not a generic library listing - see * GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one - * level further in (artist.js, then album.js). + * level further in (views/artist, then views/album). */ -export async function renderLibrary(root, params) { +export async function renderLibrary(root: HTMLElement, params: RouteParams): Promise { root.innerHTML = '

Chargement...

'; let libraries; try { libraries = await getLibraries(); - } catch (err) { + } catch { root.innerHTML = '

Impossible de charger les bibliothèques.

'; return; } @@ -32,17 +34,16 @@ export async function renderLibrary(root, params) { return; } - root.innerHTML = ` - -

Chargement...

`; + root.innerHTML = html; + root.querySelector('h1')!.textContent = library.name; - const grid = root.querySelector('#grid'); + const grid = root.querySelector('#grid') as HTMLElement; let items; try { items = library.collectionType === 'music' ? await getArtists(library.id) : await getLibraryItems(library.id); - } catch (err) { + } catch { grid.innerHTML = '

Impossible de charger cette bibliothèque.

'; return; } diff --git a/client/src/views/login/Login.html b/client/src/views/login/Login.html new file mode 100644 index 0000000..23c70d3 --- /dev/null +++ b/client/src/views/login/Login.html @@ -0,0 +1,14 @@ +
+

Watch Party

+
+

Utilise ton compte Emby habituel.

+ + + +

+
+
diff --git a/client/src/views/login/Login.ts b/client/src/views/login/Login.ts new file mode 100644 index 0000000..b8bd46f --- /dev/null +++ b/client/src/views/login/Login.ts @@ -0,0 +1,22 @@ +import html from './Login.html'; +import { login } from '../../api/embyApi'; +import { boot } from '../../app'; + +export function renderLogin(root: HTMLElement): void { + root.innerHTML = html; + + const form = root.querySelector('#loginForm') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const errorEl = form.querySelector('#error') as HTMLElement; + errorEl.textContent = ''; + const username = (form.querySelector('#username') as HTMLInputElement).value; + const password = (form.querySelector('#password') as HTMLInputElement).value; + try { + await login(username, password); + await boot(); // no shell/router running yet at the login screen - (re)boot into it now + } catch { + errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe."; + } + }); +} diff --git a/client/src/views/musicPlayer/MusicPlayer.css b/client/src/views/musicPlayer/MusicPlayer.css new file mode 100644 index 0000000..1daec71 --- /dev/null +++ b/client/src/views/musicPlayer/MusicPlayer.css @@ -0,0 +1,61 @@ +/* Music player's "now playing" header. Not inside a .playerWrap like the + video player: there's no video image to declutter around, so this + renders as a normal page (sidebar stays up) rather than theater-mode + full-viewport - see Player.css for the contrast. */ +.nowPlaying { + display: flex; + gap: 16px; + align-items: center; + background: var(--bg-header); + border-radius: 10px; + padding: 14px; + margin-bottom: 20px; +} + +.nowPlayingImage { + width: 96px; + aspect-ratio: 1 / 1; + object-fit: cover; + border-radius: 8px; + background: var(--panel); + flex-shrink: 0; +} + +.nowPlayingImage.noImage { visibility: hidden; } + +.nowPlayingInfo { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.nowPlayingInfo strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.nowPlayingControls { + display: flex; + align-items: center; + gap: 12px; + margin-top: 6px; +} + +.nowPlayingControls .iconBtn { font-size: 20px; } + +/* The presence row (styles/presence.css's .avatar) in normal page flow + here, not absolutely positioned over a video frame like the video + player's. */ +.musicPresence { margin-bottom: 16px; } + +.browsePanel { + background: var(--bg-header); + border-radius: 10px; + padding: 14px; + margin-top: 8px; +} + +.browseList { + display: flex; + flex-direction: column; + gap: 2px; +} + +.browseItem { + display: block; + width: 100%; + text-align: left; + padding: 8px 4px; +} diff --git a/client/src/views/musicPlayer/MusicPlayer.html b/client/src/views/musicPlayer/MusicPlayer.html new file mode 100644 index 0000000..4db71f2 --- /dev/null +++ b/client/src/views/musicPlayer/MusicPlayer.html @@ -0,0 +1,28 @@ + + +
+ +
+ +
+ - + +
+ + 0:00 / 0:00 + +
+
+
+ + + +

Rien en attente.

+ +
diff --git a/client/js/views/musicPlayer.js b/client/src/views/musicPlayer/MusicPlayer.ts similarity index 70% rename from client/js/views/musicPlayer.js rename to client/src/views/musicPlayer/MusicPlayer.ts index 8213a24..f07af9a 100644 --- a/client/js/views/musicPlayer.js +++ b/client/src/views/musicPlayer/MusicPlayer.ts @@ -1,10 +1,17 @@ -import { getLibraries, getArtists, getArtistAlbums, getAlbumTracks, audioStreamUrl } from '../embyApi.js'; -import { leaveParty, connectParty, addTrackToQueue } from '../party.js'; -import { navigate } from '../router.js'; -import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles.js'; +import '../../styles/presence.css'; +import '../../styles/trackList.css'; +import './MusicPlayer.css'; +import html from './MusicPlayer.html'; +import { audioStreamUrl, getAlbumTracks, getArtistAlbums, getArtists, getLibraries } from '../../api/embyApi'; +import { addTrackToQueue, connectParty, leaveParty } from '../../api/party'; +import type { PartyConnection } from '../../api/party'; +import { navigate } from '../../router'; +import type { CleanupFn } from '../../router'; +import { colorForName, escapeHtml, formatDuration, initialsFor } from '../../tiles'; +import type { MusicCurrentPublic, MusicPublicState, MusicTrackInput, PartyPublicState, SocketMember } from '../../types'; /** - * Music half of the party route (see player.js's dispatcher). Deliberately + * Music half of the party route (see Player.ts's dispatcher). Deliberately * NOT full-viewport/theater-mode like the video player: there's no image * to declutter around, so the sidebar stays up and this renders as a * normal page - which also means "browse and add a track" can happen @@ -14,61 +21,33 @@ import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles. * add a track to the shared queue at any time; anyone can pause/resume; * skipping the current track needs a strict majority vote of currently * connected members. No host-only controls at all - see - * server/src/musicQueueService.js for the full reasoning and the + * server/src/services/MusicQueueService.ts for the full reasoning and the * server-authoritative auto-advance timer that drives this without any * single client having to be "in charge". */ -export function renderMusicPlayer(root, partyId, party) { - root.innerHTML = ` - +export function renderMusicPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn { + root.innerHTML = html; -
+ const audio = root.querySelector('#audio') as HTMLAudioElement; + const presenceOverlay = root.querySelector('#presenceOverlay')!; + const trackImage = root.querySelector('#trackImage') as HTMLImageElement; + const trackName = root.querySelector('#trackName')!; + const trackArtist = root.querySelector('#trackArtist')!; + const playPauseBtn = root.querySelector('#playPauseBtn') as HTMLButtonElement; + const trackPosition = root.querySelector('#trackPosition')!; + const voteSkipBtn = root.querySelector('#voteSkipBtn') as HTMLButtonElement; + const queueList = root.querySelector('#queueList')!; + const browsePanel = root.querySelector('#browsePanel')!; -
- -
- - - -
- - 0:00 / 0:00 - -
-
-
- - - -

Rien en attente.

- -
`; - - const audio = root.querySelector('#audio'); - const presenceOverlay = root.querySelector('#presenceOverlay'); - const trackImage = root.querySelector('#trackImage'); - const trackName = root.querySelector('#trackName'); - const trackArtist = root.querySelector('#trackArtist'); - const playPauseBtn = root.querySelector('#playPauseBtn'); - const trackPosition = root.querySelector('#trackPosition'); - const voteSkipBtn = root.querySelector('#voteSkipBtn'); - const queueList = root.querySelector('#queueList'); - const browsePanel = root.querySelector('#browsePanel'); - - let conn = null; - let hls = null; - let currentItemId = null; + let conn: PartyConnection | null = null; + let hls: HlsInstance | null = null; + let currentItemId: string | null = null; let currentDurationSeconds = 0; - let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as player.js - let positionTicker = null; - let latestMembers = []; + let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as Player.ts + let positionTicker: number | null = null; + let latestMembers: SocketMember[] = []; - // Same HLS pipeline as the video player (player.js's loadVideo) - Emby's + // Same HLS pipeline as the video player (Player.ts's loadVideo) - Emby's // PlaybackInfo negotiation isn't video-specific, so its TranscodingUrl // for a music track just as plausibly comes back HLS-shaped, and most // browsers (everything but Safari) have no native .m3u8 support at all - @@ -77,7 +56,7 @@ export function renderMusicPlayer(root, partyId, party) { // is actually parsed, not immediately after setting src - setting // currentTime before a fresh source has loaded metadata is unreliable // and gets silently reset by most browsers. - function loadTrack(track, elapsedSeconds, isPaused) { + function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean) { currentItemId = track.itemId; currentDurationSeconds = track.durationSeconds || 0; trackImage.src = track.itemImageUrl || ''; @@ -101,16 +80,16 @@ export function renderMusicPlayer(root, partyId, party) { // Caught, not left to propagate: this runs from inside applyMusicState, // which every "queueUpdated" push calls - an uncaught throw here would // abort that entire handler (and, the first time it happens, the - // player's whole synchronous init - see player.js's dispatcher), taking + // player's whole synchronous init - see Player.ts's dispatcher), taking // the queue list and vote button down with a problem that's really // just "this one track's audio failed to load". A skip vote is still - // this feature's actual recovery path (see musicQueueService.js) - this + // this feature's actual recovery path (see MusicQueueService) - this // just needs to not crash on the way there. try { if (window.Hls && window.Hls.isSupported()) { hls = new window.Hls(); hls.on(window.Hls.Events.MANIFEST_PARSED, resume); - hls.on(window.Hls.Events.ERROR, (event, data) => { + hls.on(window.Hls.Events.ERROR, (_event, data) => { if (data.fatal) console.error('[watchparty musicPlayer] HLS error', data); }); hls.loadSource(src); @@ -126,19 +105,19 @@ export function renderMusicPlayer(root, partyId, party) { } } - function applyMusicState(music) { + function applyMusicState(music: MusicPublicState) { // Queue/vote-button/play-icon updates happen first, unconditionally - // deliberately BEFORE the track-loading branch below, which touches // hls.js and could throw (a bad manifest, a network hiccup). Same // ordering the video player already uses (see onItemChanged in - // player.js, which updates the title/next-button before calling + // Player.ts, which updates the title/next-button before calling // loadVideo): a media-loading failure should never also take the // queue list and vote tally down with it, since they're not actually // related to whether THIS particular track's audio loaded. playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶'; playPauseBtn.disabled = !music.current; voteSkipBtn.disabled = !music.current; - // "needed" mirrors the exact math musicQueueService.js uses server-side + // "needed" mirrors the exact math MusicQueueService uses server-side // (strict majority of currently connected members) - computed here from // the member list the "members" event already provides, rather than // MusicQueueService's own broadcast repeating that count on every @@ -191,18 +170,18 @@ export function renderMusicPlayer(root, partyId, party) { conn.setMusicPaused(!audio.paused); }); - voteSkipBtn.addEventListener('click', () => { if (conn) conn.voteSkip(); }); + voteSkipBtn.addEventListener('click', () => conn?.voteSkip()); // Ticks the visible position label between server updates - the same // "derive it client-side from a snapshot" approach as the home page's // party-elapsed-time labels, rather than the server pushing this every // second for every listener. - positionTicker = setInterval(() => { + positionTicker = window.setInterval(() => { if (!currentItemId) return; trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`; }, 500); - function renderMembers(members) { + function renderMembers(members: SocketMember[]) { latestMembers = members; presenceOverlay.innerHTML = members.map((m) => `
@@ -214,7 +193,7 @@ export function renderMusicPlayer(root, partyId, party) { // within this page (see class comment for why not a route navigation). --- let browsing = false; - root.querySelector('#toggleBrowseBtn').addEventListener('click', () => { + root.querySelector('#toggleBrowseBtn')!.addEventListener('click', () => { browsing = !browsing; browsePanel.classList.toggle('hide', !browsing); if (browsing) loadMusicLibraries(); @@ -231,92 +210,95 @@ export function renderMusicPlayer(root, partyId, party) { // Almost always exactly one - skip straight to its artists rather // than making the common case click through a one-item list. if (libraries.length === 1) { - loadArtists(libraries[0].id); + loadArtists(libraries[0]!.id); } else { - browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), null); + browsePanel.innerHTML = renderBrowseList(libraries.map((l) => ({ id: l.id, name: l.name })), false); wireBrowseList((id) => loadArtists(id)); } - } catch (err) { + } catch { browsePanel.innerHTML = '

Impossible de charger les bibliothèques.

'; } } - async function loadArtists(libraryId) { + async function loadArtists(libraryId: string) { browsePanel.innerHTML = '

Chargement...

'; try { const artists = await getArtists(libraryId); - browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), null); + browsePanel.innerHTML = renderBrowseList(artists.map((a) => ({ id: a.id, name: a.name })), false); wireBrowseList((id) => loadAlbums(id)); - } catch (err) { + } catch { browsePanel.innerHTML = '

Impossible de charger les artistes.

'; } } - async function loadAlbums(artistId) { + async function loadAlbums(artistId: string) { browsePanel.innerHTML = '

Chargement...

'; try { const albums = await getArtistAlbums(artistId); - browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), () => loadMusicLibraries()); + browsePanel.innerHTML = renderBrowseList(albums.map((a) => ({ id: a.id, name: a.year ? `${a.name} (${a.year})` : a.name })), true); wireBrowseList((id) => loadTracks(id)); - } catch (err) { + } catch { browsePanel.innerHTML = '

Impossible de charger les albums.

'; } } - async function loadTracks(albumId) { + async function loadTracks(albumId: string) { browsePanel.innerHTML = '

Chargement...

'; try { const tracks = await getAlbumTracks(albumId); browsePanel.innerHTML = ` - ${tracks.map((t) => ` + ${tracks.map((t) => { + const input: MusicTrackInput = { + itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName, + itemImageUrl: t.imageUrl, durationTicks: t.durationTicks + }; + return `
${t.indexNumber != null ? t.indexNumber : ''} ${escapeHtml(t.name)} ${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''} - -
`).join('')}`; - browsePanel.querySelector('.browseBack').addEventListener('click', () => loadMusicLibraries()); - browsePanel.querySelectorAll('.addTrackBtn').forEach((btn) => { + +
`; + }).join('')}`; + browsePanel.querySelector('.browseBack')!.addEventListener('click', () => loadMusicLibraries()); + browsePanel.querySelectorAll('.addTrackBtn').forEach((btn) => { btn.addEventListener('click', async () => { btn.disabled = true; - const originalLabel = btn.textContent; + const originalLabel = btn.textContent as string; try { - await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track))); + await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track as string))); btn.textContent = 'Ajouté !'; setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500); } catch (err) { - alert(err.message); + alert((err as Error).message); btn.disabled = false; } }); }); - } catch (err) { + } catch { browsePanel.innerHTML = '

Impossible de charger les morceaux.

'; } } - function renderBrowseList(items, onBack) { - const back = onBack ? '' : ''; + function renderBrowseList(items: { id: string; name: string }[], withBack: boolean): string { + const back = withBack ? '' : ''; const body = items.length ? items.map((i) => ``).join('') : '

Rien à afficher.

'; return `${back}
${body}
`; } - function wireBrowseList(onPick) { + function wireBrowseList(onPick: (id: string) => void) { const back = browsePanel.querySelector('.browseBack'); if (back) back.addEventListener('click', () => loadMusicLibraries()); - browsePanel.querySelectorAll('.browseItem').forEach((btn) => { - btn.addEventListener('click', () => onPick(btn.dataset.id)); + browsePanel.querySelectorAll('.browseItem').forEach((btn) => { + btn.addEventListener('click', () => onPick(btn.dataset.id as string)); }); } // --- Init --- - applyMusicState(party.music); + if (party.music) applyMusicState(party.music); conn = connectParty(partyId, { onMembers: renderMembers, @@ -325,14 +307,14 @@ export function renderMusicPlayer(root, partyId, party) { onClose: () => {} }); - root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home')); + (root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home')); return function cleanup() { if (positionTicker) clearInterval(positionTicker); if (hls) hls.destroy(); audio.pause(); audio.removeAttribute('src'); - if (conn) conn.leave(); + conn?.leave(); leaveParty(partyId).catch(() => {}); }; } diff --git a/client/src/views/player/Player.css b/client/src/views/player/Player.css new file mode 100644 index 0000000..cad6f09 --- /dev/null +++ b/client/src/views/player/Player.css @@ -0,0 +1,195 @@ +.playerWrap { + /* Fixed + inset so it fills the actual viewport regardless of + .mainContent's own padding (which used to add to 100vh and push the + whole page taller than the screen, causing an outer scrollbar). Also + covers the sidebar while playing, like most video players' theater + mode - "Quitter" is how you get back to it. */ + position: fixed; + inset: 0; + z-index: 20; + background: #000; +} + +.playerWrap video { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Two stacked rows, both part of the same top gradient overlay: the action + bar (title/members/next/settings/leave) and, below it, the presence + avatars - see .presenceOverlay in styles/presence.css. Stacking them in + normal flow inside one column means the avatar row always ends up right + after the action bar regardless of how tall that bar is (it wraps to + two lines on narrow screens), instead of guessing a fixed pixel offset + for a second absolutely-positioned block. */ +.topOverlay { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px; + background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent); + z-index: 5; +} + +.actionBar { + display: flex; + align-items: center; + gap: 16px; + color: white; + font-size: 13px; + flex-wrap: wrap; +} + +.actionBar .itemTitle { font-weight: 600; } +.actionBar .members { color: var(--muted); flex: 1; } + +/* Settings: a small gear icon in the action bar, not a permanently-visible + block of dropdowns - opens a compact popup on click. */ +.settingsWrap { position: relative; } + +.iconBtn { + background: none; + padding: 4px 8px; + font-size: 16px; + line-height: 1; + border-radius: 6px; +} + +.iconBtn:hover { background: rgba(255, 255, 255, .12); } + +.settingsPanel { + position: absolute; + top: 100%; + right: 0; + margin-top: 6px; + display: flex; + flex-direction: column; + gap: 10px; + background: var(--bg-header); + padding: 12px; + border-radius: 10px; + font-size: 12px; + min-width: 180px; + box-shadow: 0 8px 24px rgba(0, 0, 0, .4); + z-index: 5; +} + +.settingsPanel label { + color: var(--muted); + margin-bottom: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.settingsPanel select { + background: var(--panel); + color: var(--text); + border: 1px solid #3a3a3a; + border-radius: 6px; + padding: 4px 6px; + font-size: 12px; +} + +.loadingNotice { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: var(--muted); + font-size: 14px; + margin: 0; +} + +.playerError { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: 360px; + text-align: center; + background: rgba(0, 0, 0, .75); + padding: 20px; + border-radius: 10px; +} + +.playerError p { color: var(--danger); font-size: 14px; margin: 0 0 10px; } + +.followerNotice { + /* margin:0 overrides the

's default UA margin - for an absolutely + positioned box, "bottom" places the MARGIN edge, so the default ~1em + bottom margin was quietly pushing the pill 13px higher than its + declared bottom:24px ever suggested. Harmless on its own, but it made + .pauseRequestBanner's bottom offset above it read as barely any gap + at all once that one was measured against this element's real position. */ + position: absolute; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + margin: 0; + background: rgba(0,0,0,.6); + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + color: var(--muted); +} + +/* Only the viewer's own avatar gets this, and only while not host - see + renderMembers() in Player.ts. The host has direct controls, so their + own circle is presence-only. */ +.avatarClickable { + cursor: pointer; + transition: opacity .15s, transform .15s; +} + +.avatarClickable:hover { + opacity: 1; + transform: scale(1.08); +} + +/* Shown to EVERY member (including the host and the requester themselves) + on the matching person's circle while their "pauseRequested" is active - + see onPauseRequested() in Player.ts. Orange border + a small pause-icon + badge, not just a generic pulse, so it reads unambiguously even to + someone glancing at the video rather than the banner text. */ +.avatarPauseRequested { + opacity: 1; + border-color: #ff9800; +} + +.avatarPauseBadge { + position: absolute; + bottom: -4px; + right: -4px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #ff9800; + border: 1px solid rgba(0, 0, 0, .5); + display: none; + align-items: center; + justify-content: center; +} + +.avatarPauseRequested .avatarPauseBadge { display: flex; } + +.pauseRequestBanner { + /* Bottom-anchored, clear of the presence row now living up in + .topOverlay - stacked above .followerNotice (bottom: 24px) rather than + on top of it, since a non-host sees both at once. */ + position: absolute; + bottom: 72px; + left: 50%; + transform: translateX(-50%); + background: rgba(0, 0, 0, .75); + color: white; + padding: 8px 16px; + border-radius: 20px; + font-size: 13px; + z-index: 6; +} diff --git a/client/src/views/player/Player.html b/client/src/views/player/Player.html new file mode 100644 index 0000000..89a3a51 --- /dev/null +++ b/client/src/views/player/Player.html @@ -0,0 +1,34 @@ +

+ + +

Chargement du flux...

+
+

+ +
+ +
+ +
+
+ + + + +
+ +
+ + + +
+
+ + +
+ +
+
+ +

L'hôte contrôle la lecture.

+
diff --git a/client/js/views/player.js b/client/src/views/player/Player.ts similarity index 70% rename from client/js/views/player.js rename to client/src/views/player/Player.ts index 14a120a..4e18ad3 100644 --- a/client/js/views/player.js +++ b/client/src/views/player/Player.ts @@ -1,12 +1,18 @@ -import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js'; -import { getParty, leaveParty, connectParty } from '../party.js'; -import { navigate } from '../router.js'; -import { escapeHtml, colorForName, initialsFor } from '../tiles.js'; -import { renderMusicPlayer } from './musicPlayer.js'; +import '../../styles/presence.css'; +import './Player.css'; +import html from './Player.html'; +import { auth, getPlaybackOptions, streamUrl } from '../../api/embyApi'; +import { getParty, leaveParty, connectParty } from '../../api/party'; +import type { PartyConnection } from '../../api/party'; +import { navigate } from '../../router'; +import type { CleanupFn, RouteParams } from '../../router'; +import { colorForName, escapeHtml, initialsFor } from '../../tiles'; +import type { ItemChangedMsg, PartyPublicState, PauseRequestedMsg, SocketMember } from '../../types'; +import { renderMusicPlayer } from '../musicPlayer/MusicPlayer'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const DRIFT_TOLERANCE_SECONDS = 1.5; -const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in socketHandler.js +const PAUSE_REQUEST_COOLDOWN_MS = 5000; // matches the server's own throttle in SocketGateway const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up // "Quality" is really "max transcoding bitrate", the same simplification @@ -35,7 +41,7 @@ const PAUSE_ICON_SVG = ` * before they can render anything at all. * * Has to return its cleanup function *synchronously* (route()'s contract - - * see router.js), but which cleanup function that even is depends on a + * see router.ts), but which cleanup function that even is depends on a * fetch that hasn't resolved yet at the time this returns. Solved with a * mutable box: an outer cleanup wrapper is handed back immediately, and it * calls whatever the real cleanup turns out to be once the fetch settles - @@ -43,11 +49,11 @@ const PAUSE_ICON_SVG = ` * when it runs (nothing was ever set up in that case - no socket, no * timer, nothing to leak). */ -export function renderPartyPlayer(root, params) { - const partyId = params.id; +export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn { + const partyId = params.id as string; root.innerHTML = '

Chargement...

'; - let cleanup = () => {}; + let cleanup: CleanupFn = () => {}; let cancelled = false; getParty(partyId).then((party) => { @@ -75,64 +81,30 @@ export function renderPartyPlayer(root, params) { * Takes the already-fetched party instead of fetching it itself: the * dispatcher needs to fetch it anyway just to read mediaType, and * route()'s contract needs its cleanup function back *synchronously* (see - * router.js) - an async renderVideoPlayer awaiting its own fetch would - * return a Promise instead, which router.js's `typeof cleanup === + * router.ts) - an async renderVideoPlayer awaiting its own fetch would + * return a Promise instead, which router.ts's `typeof cleanup === * 'function'` check would silently skip, reintroducing the exact leak * class this app already spent real effort fixing once. */ -function renderVideoPlayer(root, partyId, party) { - root.innerHTML = ` -
- +function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn { + root.innerHTML = html; -

Chargement du flux...

-
-

- -
- -
- -
-
- - - - -
- -
- - - -
-
- - -
- -
-
- -

L'hôte contrôle la lecture.

-
`; - - const video = root.querySelector('#video'); - const loadingNotice = root.querySelector('#loadingNotice'); - const errorBox = root.querySelector('#playerError'); - const errorText = root.querySelector('#playerErrorText'); - const itemTitleEl = root.querySelector('#itemTitle'); - const membersEl = root.querySelector('#members'); - const presenceOverlay = root.querySelector('#presenceOverlay'); - const pauseBanner = root.querySelector('#pauseRequestBanner'); - const nextBtn = root.querySelector('#nextBtn'); - const followerNotice = root.querySelector('#followerNotice'); - const settingsWrap = root.querySelector('#settingsWrap'); - const settingsBtn = root.querySelector('#settingsBtn'); - const settingsPanel = root.querySelector('#settingsPanel'); - const audioSelect = root.querySelector('#audioSelect'); - const subtitleSelect = root.querySelector('#subtitleSelect'); - const qualitySelect = root.querySelector('#qualitySelect'); + const video = root.querySelector('#video') as HTMLVideoElement; + const loadingNotice = root.querySelector('#loadingNotice')!; + const errorBox = root.querySelector('#playerError')!; + const errorText = root.querySelector('#playerErrorText')!; + const itemTitleEl = root.querySelector('#itemTitle')!; + const membersEl = root.querySelector('#members')!; + const presenceOverlay = root.querySelector('#presenceOverlay')!; + const pauseBanner = root.querySelector('#pauseRequestBanner')!; + const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement; + const followerNotice = root.querySelector('#followerNotice')!; + const settingsWrap = root.querySelector('#settingsWrap')!; + const settingsBtn = root.querySelector('#settingsBtn')!; + const settingsPanel = root.querySelector('#settingsPanel')!; + const audioSelect = root.querySelector('#audioSelect') as HTMLSelectElement; + const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement; + const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement; qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => ``).join(''); @@ -142,9 +114,9 @@ function renderVideoPlayer(root, partyId, party) { // never be reachable to remove, and would pile up across visits. This was // the actual leak: every join-a-party-then-navigate-away-without-"Quitter" // used to leave one of these (plus the heartbeat interval, the socket, - // and the hls.js instance) running forever - see router.js's cleanup hook. - function onDocumentClick(e) { - if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide'); + // and the hls.js instance) running forever - see router.ts's cleanup hook. + function onDocumentClick(e: MouseEvent) { + if (!settingsWrap.contains(e.target as Node)) settingsPanel.classList.add('hide'); } document.addEventListener('click', onDocumentClick); @@ -155,15 +127,15 @@ function renderVideoPlayer(root, partyId, party) { let isHost = false; let hasNextQueued = false; - let conn = null; - let hls = null; - let currentItemId = null; + let conn: PartyConnection | null = null; + let hls: HlsInstance | null = null; + let currentItemId: string | null = null; let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - let heartbeat = null; + let heartbeat: number | null = null; let pauseRequestCooldownUntil = 0; - let pauseBannerTimer = null; + let pauseBannerTimer: number | null = null; - function showError(message, detail) { + function showError(message: string, detail?: unknown) { loadingNotice.classList.add('hide'); errorText.textContent = message; errorBox.classList.remove('hide'); @@ -174,7 +146,7 @@ function renderVideoPlayer(root, partyId, party) { errorBox.classList.add('hide'); } - function applyRemoteState(msg) { + function applyRemoteState(msg: { isPaused: boolean; positionTicks: number }) { suppressLocalEvents = true; const targetSeconds = msg.positionTicks / TICKS_PER_SECOND; if (Math.abs(video.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) { @@ -193,15 +165,15 @@ function renderVideoPlayer(root, partyId, party) { /** * Promotes this client to host - called either at init (if we already * were) or on a live "hostChanged" event (the previous host left and the - * server picked us next - see socketHandler.js's applyLeave). Safe to - * call more than once: the early return covers the normal case, and - * addEventListener with the same function reference is a no-op if already - * attached, so even a re-entrant call can't double up the play/pause/ - * seeked listeners. The heartbeat is guarded separately since setInterval - * has no such built-in idempotence - calling it twice would leak the - * first interval's id. + * server picked us next - see SocketGateway's applyLeave). Safe to call + * more than once: the early return covers the normal case, and + * addEventListener with the same function reference is a no-op if + * already attached, so even a re-entrant call can't double up the + * play/pause/seeked listeners. The heartbeat is guarded separately since + * setInterval has no such built-in idempotence - calling it twice would + * leak the first interval's id. */ - function becomeHost(notify) { + function becomeHost(notify: boolean) { if (isHost) return; isHost = true; video.controls = true; @@ -212,13 +184,13 @@ function renderVideoPlayer(root, partyId, party) { video.addEventListener('play', sendLocalState); video.addEventListener('pause', sendLocalState); video.addEventListener('seeked', sendLocalState); - video.addEventListener('ended', () => { if (hasNextQueued) conn.next(); }); - if (!heartbeat) heartbeat = setInterval(sendLocalState, 3000); + video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); }); + if (!heartbeat) heartbeat = window.setInterval(sendLocalState, 3000); if (notify) alert("L'hôte précédent a quitté la party - tu es maintenant l'hôte."); } - async function loadTrackOptions(itemId) { + async function loadTrackOptions(itemId: string) { try { const options = await getPlaybackOptions(itemId); audioSelect.innerHTML = options.audioTracks.length @@ -232,7 +204,7 @@ function renderVideoPlayer(root, partyId, party) { } } - function loadVideo(itemId, resumeSeconds, wasPlaying) { + function loadVideo(itemId: string, resumeSeconds: number, wasPlaying: boolean) { currentItemId = itemId; clearError(); loadingNotice.classList.remove('hide'); @@ -257,7 +229,7 @@ function renderVideoPlayer(root, partyId, party) { if (window.Hls && window.Hls.isSupported()) { hls = new window.Hls(); hls.on(window.Hls.Events.MANIFEST_PARSED, resume); - hls.on(window.Hls.Events.ERROR, (event, data) => { + hls.on(window.Hls.Events.ERROR, (_event, data) => { if (!data.fatal) return; showError( "Erreur de lecture (HLS) - le flux n'a pas pu être chargé depuis Emby.", @@ -280,10 +252,10 @@ function renderVideoPlayer(root, partyId, party) { } // Changing language/subtitles/quality means a fresh transcode (Emby bakes - // the selection in server-side - see index.js) - reload at the same - // position instead of restarting from zero. + // the selection in server-side - see CatalogService) - reload at the + // same position instead of restarting from zero. function reloadWithCurrentOptions() { - loadVideo(currentItemId, video.currentTime, !video.paused); + if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused); } [audioSelect, subtitleSelect, qualitySelect].forEach((sel) => { @@ -293,11 +265,11 @@ function renderVideoPlayer(root, partyId, party) { }); }); - root.querySelector('#playerErrorRetry').addEventListener('click', () => { + (root.querySelector('#playerErrorRetry') as HTMLButtonElement).addEventListener('click', () => { if (currentItemId) loadVideo(currentItemId, video.currentTime, true); }); - async function onItemChanged(msg) { + async function onItemChanged(msg: ItemChangedMsg) { itemTitleEl.textContent = msg.itemName || ''; hasNextQueued = !!msg.hasNext; nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); @@ -312,7 +284,7 @@ function renderVideoPlayer(root, partyId, party) { // The pause-badge is always in the markup, just hidden by // .avatarPauseRequested's CSS - see onPauseRequested(), which is the only // thing that ever shows it. - function renderMembers(members) { + function renderMembers(members: SocketMember[]) { membersEl.textContent = members .map((m) => m.userName + (m.isHost ? ' (hôte)' : '')) .join(', '); @@ -342,13 +314,13 @@ function renderVideoPlayer(root, partyId, party) { } // Broadcast to everyone in the party, including the requester and the - // host (see socketHandler.js) - so this always flags the right circle no + // host (see SocketGateway) - so this always flags the right circle no // matter whose screen it renders on. - function onPauseRequested(msg) { + function onPauseRequested(msg: PauseRequestedMsg) { pauseBanner.textContent = `${msg.userName} demande une pause`; pauseBanner.classList.remove('hide'); if (pauseBannerTimer) clearTimeout(pauseBannerTimer); - pauseBannerTimer = setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS); + pauseBannerTimer = window.setTimeout(() => pauseBanner.classList.add('hide'), PAUSE_REQUEST_HIGHLIGHT_MS); const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`); if (avatar) { @@ -378,12 +350,12 @@ function renderVideoPlayer(root, partyId, party) { if (party.youAreHost) becomeHost(false); } - nextBtn.addEventListener('click', () => { if (conn) conn.next(); }); - root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home')); + nextBtn.addEventListener('click', () => { conn?.next(); }); + (root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home')); init(); - // Called by router.js right before it renders whatever route comes next - + // Called by router.ts right before it renders whatever route comes next - // whether that's from clicking "Quitter" (navigate above) or leaving any // other way (sidebar link, browser back, ...). This is the fix for the // actual leak: previously, only the "Quitter" button ran any of this. @@ -392,7 +364,7 @@ function renderVideoPlayer(root, partyId, party) { if (pauseBannerTimer) clearTimeout(pauseBannerTimer); if (hls) hls.destroy(); document.removeEventListener('click', onDocumentClick); - if (conn) conn.leave(); + conn?.leave(); leaveParty(partyId).catch(() => {}); }; } diff --git a/client/src/views/series/Series.css b/client/src/views/series/Series.css new file mode 100644 index 0000000..19a7d20 --- /dev/null +++ b/client/src/views/series/Series.css @@ -0,0 +1,91 @@ +/* ---- Series page: detail header + seasons tabs + episode list ---- */ + +.seriesHeader { + display: flex; + gap: 20px; + margin-bottom: 24px; +} + +.seriesPoster { + width: 160px; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 8px; + background: var(--panel); + flex-shrink: 0; +} + +.seriesPoster.noImage { visibility: hidden; } + +@media (max-width: 600px) { + .seriesHeader { flex-direction: column; } + .seriesPoster { width: 120px; } +} + +/* Tabs: one per season. */ +.tabs { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,.1); +} + +.tab { + background: none; + color: var(--muted); + font-weight: 600; + padding: 8px 4px; + border-radius: 0; + white-space: nowrap; + border-bottom: 2px solid transparent; +} + +.tab:hover { background: none; color: var(--text); } +.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); } + +.episodeList { + display: flex; + flex-direction: column; + gap: 12px; +} + +.episodeRow { + display: flex; + gap: 14px; + align-items: center; + background: var(--bg-header); + border-radius: 10px; + padding: 10px; +} + +.episodeRow img { + width: 120px; + aspect-ratio: 16 / 9; + object-fit: cover; + border-radius: 6px; + background: var(--panel); + flex-shrink: 0; +} + +.episodeRow img.noImage { visibility: hidden; } + +.episodeInfo { flex: 1; min-width: 0; } +.episodeInfo strong { display: block; margin-bottom: 4px; } +.episodeInfo p { + margin: 0; + color: var(--muted); + font-size: 13px; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.episodeRow button { flex-shrink: 0; } + +@media (max-width: 600px) { + .episodeRow { flex-wrap: wrap; } + .episodeInfo { order: 3; flex-basis: 100%; } +} diff --git a/client/src/views/series/Series.html b/client/src/views/series/Series.html new file mode 100644 index 0000000..b6d142b --- /dev/null +++ b/client/src/views/series/Series.html @@ -0,0 +1,11 @@ + +
+ +
+

+

+ Voir sur Emby +
+
+ +

Chargement...

diff --git a/client/src/views/series/Series.ts b/client/src/views/series/Series.ts new file mode 100644 index 0000000..146255f --- /dev/null +++ b/client/src/views/series/Series.ts @@ -0,0 +1,103 @@ +import './Series.css'; +import html from './Series.html'; +import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi'; +import { createParty } from '../../api/party'; +import { navigate } from '../../router'; +import type { RouteParams } from '../../router'; +import { escapeHtml } from '../../tiles'; +import type { ItemDto, SeasonDto } from '../../types'; + +export async function renderSeries(root: HTMLElement, params: RouteParams): Promise { + const seriesId = params.id as string; + root.innerHTML = '

Chargement...

'; + + let series: ItemDto; + let seasons: SeasonDto[]; + try { + [series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]); + } catch { + root.innerHTML = '

Impossible de charger cette série.

'; + return; + } + + root.innerHTML = html; + root.querySelector('#back')!.addEventListener('click', () => history.back()); + (root.querySelector('#seriesPoster') as HTMLImageElement).src = series.imageUrl; + root.querySelector('#seriesTitle')!.innerHTML = series.year + ? `${escapeHtml(series.name)} (${series.year})` + : escapeHtml(series.name); + if (series.overview) { + const overviewEl = root.querySelector('#seriesOverview')!; + overviewEl.textContent = series.overview; + overviewEl.classList.remove('hide'); + } + (root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl; + + if (!seasons.length) { + root.querySelector('#episodeList')!.innerHTML = '

Aucune saison trouvée.

'; + return; + } + + const tabsEl = root.querySelector('#seasonTabs')!; + tabsEl.innerHTML = seasons + .map((s, i) => ``) + .join(''); + + tabsEl.querySelectorAll('.tab').forEach((tab) => { + tab.addEventListener('click', () => { + if (tab.classList.contains('active')) return; + tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active')); + tab.classList.add('active'); + loadEpisodes(root, seriesId, tab.dataset.seasonId as string); + }); + }); + + loadEpisodes(root, seriesId, seasons[0]!.id); +} + +async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise { + const listEl = root.querySelector('#episodeList')!; + listEl.innerHTML = '

Chargement...

'; + + let episodes: ItemDto[]; + try { + episodes = await getSeriesEpisodes(seriesId, seasonId); + } catch { + listEl.innerHTML = '

Impossible de charger les épisodes.

'; + return; + } + if (!episodes.length) { + listEl.innerHTML = '

Aucun épisode dans cette saison.

'; + return; + } + + // Starting a party from any episode here queues the rest of the series + // from that point on server-side (see CatalogService/index.ts) - the + // host can then use "Épisode suivant" in the player instead of + // restarting per episode. + listEl.innerHTML = episodes.map((ep) => ` +
+ +
+ ${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)} +

${escapeHtml(ep.overview || '')}

+
+ +
+ `).join(''); + + listEl.querySelectorAll('.startEpisodeBtn').forEach((btn) => { + btn.addEventListener('click', async () => { + btn.disabled = true; + btn.textContent = 'Démarrage...'; + try { + const party = await createParty(btn.dataset.itemId as string); + navigate(`/party/${party.partyId}`); + } catch (err) { + alert((err as Error).message); + btn.disabled = false; + btn.textContent = 'Lancer une watch party'; + } + }); + }); +} diff --git a/client/tsconfig.json b/client/tsconfig.json new file mode 100644 index 0000000..358c10c --- /dev/null +++ b/client/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/docs/architecture.md b/docs/architecture.md index 4c8cc5e..f70b01b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,18 +52,28 @@ possible and this gets revisited. ## Components ``` -server/ Node.js + Express + Socket.IO. Owns party/room state and the - realtime sync channel. Never touches a media byte - see "Media - never flows through our backend" below. +server/ Node.js + Express + Socket.IO, TypeScript (compiled by tsc). + Proxies 100% of Emby access (catalog, images, HLS/audio + streaming) and owns party/room state + the realtime sync + channel - see "Auth and Emby access" right below for why it's + everything, not just party coordination, and "TypeScript and a + service-oriented architecture" further down for the current + file-by-file layout (one singleton service class per feature). -client/ Static frontend, plain JS + ES modules, no build step (same bias - as the earlier plugin's control panel). Talks to two different - things and must not confuse them: - - Emby directly (embyApi.js): catalog, images, streaming. - - Our own backend (party.js): create/join/leave a party, and - the Socket.IO connection for realtime sync. +client/ Frontend, TypeScript, esbuild-bundled to one app.js + one + app.css (client/dist) - no framework. Talks only to server/, + never to Emby directly (api/embyApi.ts and api/party.ts are + both just fetch/Socket.IO wrappers around this app's own + /api/* routes). One folder per view, each colocating its own + .ts/.html/.css - see "TypeScript and a service-oriented + architecture" further down. ``` +Both projects predate this exact shape - see git history and "TypeScript +and a service-oriented architecture" further down for the plain-JS -> +TypeScript rewrite itself, including why (a deliberate, requested choice, +not a default) and what changed structurally. + ### Auth and Emby access: the server proxies everything Revised after the first version: the browser used to call Emby directly @@ -873,6 +883,215 @@ same way a video's does (reasoned to, per "Streaming" above, not observed), and the real audio quality/gapless-ness of playback through a transcode. First things to check once this gets a real retest. +## TypeScript and a service-oriented architecture + +A full rewrite, requested directly: both `server/` and `client/` are now +TypeScript, and the server is organized as one singleton service class per +feature rather than route handlers with inline logic. This is the largest +single change in this project's history - it touches nearly every file - +so this section covers the shape of it rather than every line moved. + +### Why this needed real decisions, not just "add types" + +This app was "no build step" from the very first version, deliberately - +the client's plain ES modules loaded straight in the browser, the server +ran its `.js` directly. TypeScript can't compile itself away at +runtime - something has to turn `.ts` into `.js` before either project +can run - so "TypeScript" and "no build step" are mutually exclusive, not +a small addition to the existing setup. Two independent decisions followed: + +- **Server: plain `tsc`.** The server doesn't need bundling (Node resolves + `require()`s itself, there's no browser payload-size concern) - just + type-stripping and down-leveling, which is exactly what `tsc` does. + `server/dist` mirrors `server/src`'s structure 1:1. +- **Client: esbuild.** The client *does* need bundling (a browser + shouldn't have to make a dozen round trips for a dozen modules, and + there's no reason to hand-manage `