Rewrite server + client in TypeScript, service-oriented server architecture
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
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/<name>/<Name>.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 <noreply@anthropic.com>
This commit is contained in:
parent
efaa7f55f0
commit
4a31ee741e
76 changed files with 5567 additions and 3286 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -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/
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
|
|
@ -10,11 +10,17 @@ obj/
|
||||||
*.swp
|
*.swp
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
## Node (server/)
|
## Node (server/, client/)
|
||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.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
|
## OS cruft
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
|
||||||
107
README.md
107
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
|
albums/tracks, `PlaybackInfo`'s media streams) and actual HLS playback
|
||||||
(video or audio) are still unverified.
|
(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
|
## 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,
|
server/ Node.js + Express + Socket.IO backend: proxies ALL Emby
|
||||||
images, HLS streaming) and coordinates party/room state +
|
access (auth, catalog, images, HLS streaming) and
|
||||||
realtime sync. The browser only ever talks to this.
|
coordinates party/room state + realtime sync. The
|
||||||
src/index.js Express app + REST endpoints
|
browser only ever talks to this. TypeScript, compiled by
|
||||||
src/embyAuth.js server-side login against Emby + session cookies
|
tsc to server/dist.
|
||||||
src/embyProxy.js generic Emby request/response proxying (incl. HLS)
|
src/index.ts Express app + route wiring only - thin,
|
||||||
src/socketHandler.js Socket.IO protocol (see comments in the file)
|
delegates to the services below
|
||||||
src/partyManager.js in-memory room state
|
src/config.ts env-derived config (EMBY_SERVER_URL, PORT)
|
||||||
src/musicQueueService.js participative music queue - see docs/architecture.md
|
src/types.ts shared interfaces (Party, ItemDto, the
|
||||||
src/logger.js party lifecycle logging (docker logs/journalctl-friendly)
|
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).
|
client/ Frontend: TypeScript, esbuild-bundled to client/dist
|
||||||
Talks only to server/ - never to Emby directly.
|
(one app.js + one app.css - see client/build.mjs).
|
||||||
js/shell.js persistent sidebar + #mainContent the router swaps
|
Talks only to server/ - never to Emby directly. Each
|
||||||
js/tiles.js poster-tile rendering shared by home.js and library.js
|
view is its own folder colocating its .ts/.html/.css.
|
||||||
js/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
|
src/app.ts entry point - registers routes, boots the app
|
||||||
js/party.js our own backend's party REST + Socket.IO client
|
src/router.ts tiny hash router (unchanged in spirit - see docs)
|
||||||
js/views/ one file per screen: login, home, library, series, player,
|
src/types.ts shared interfaces (client-side copies of the
|
||||||
artist, album, musicPlayer
|
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,
|
src/WatchParty/ The earlier Emby-plugin attempt (C#, unrelated to the
|
||||||
but Emby has no supported extension point for a player
|
TypeScript above). Builds fine and installs, but Emby
|
||||||
button, and even menu integration required reverse-
|
has no supported extension point for a player button,
|
||||||
engineering undocumented internals - see
|
and even menu integration required reverse-engineering
|
||||||
docs/architecture.md for the full story. Kept for
|
undocumented internals - see docs/architecture.md for
|
||||||
reference; not the active direction.
|
the full story. Kept for reference; not the active
|
||||||
|
direction.
|
||||||
|
|
||||||
docs/architecture.md full design doc for the standalone app
|
docs/architecture.md full design doc for the standalone app
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running locally
|
## 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
|
```bash
|
||||||
cd server
|
cd client && npm install && npm run build && cd ..
|
||||||
npm install
|
cd server && npm install && npm run build
|
||||||
EMBY_SERVER_URL=https://your-emby-server npm start
|
EMBY_SERVER_URL=https://your-emby-server npm start
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open `http://localhost:3000`.
|
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
|
## Deploying
|
||||||
|
|
||||||
Build the Docker image from the **repo root** (it needs both `server/` and
|
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
|
```bash
|
||||||
docker build -f server/Dockerfile -t watchparty .
|
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
|
host handoff, play/pause, pause requests) — see docs/architecture.md
|
||||||
- [x] Music: dedicated player, Artist/Album/Track browsing, participative
|
- [x] Music: dedicated player, Artist/Album/Track browsing, participative
|
||||||
queue (free add, vote-to-skip, no host) — see docs/architecture.md
|
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)
|
- [ ] Try a full party end-to-end with two real accounts (still the main gap)
|
||||||
- [ ] Tune transcoding target / support direct play when possible
|
- [ ] Tune transcoding target / support direct play when possible
|
||||||
|
|
||||||
|
|
|
||||||
47
client/build.mjs
Normal file
47
client/build.mjs
Normal file
|
|
@ -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');
|
||||||
|
}
|
||||||
|
|
@ -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 <a class="linklike"> - e.g. the
|
|
||||||
"Voir sur Emby" link on the series detail page - gets the same treatment
|
|
||||||
as the <button> ones without needing its own duplicate rule; there's no
|
|
||||||
global `a` reset in this file, so an unclassed anchor would otherwise
|
|
||||||
fall back to the browser's default blue/underline against this dark
|
|
||||||
theme. */
|
|
||||||
.linklike {
|
|
||||||
background: none;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 400;
|
|
||||||
text-decoration: underline;
|
|
||||||
padding: 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.linklike:hover { background: none; color: var(--accent-bright); }
|
|
||||||
|
|
||||||
.code { color: var(--accent-bright); letter-spacing: .1em; }
|
|
||||||
|
|
||||||
/* ---- App shell: sidebar + routed main content ---- */
|
|
||||||
|
|
||||||
.appShell {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 220px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--bg-header);
|
|
||||||
padding: 20px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
height: 100vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarBrand {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0 12px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebarNav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navSectionLabel {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 11px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: .05em;
|
|
||||||
padding: 16px 12px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navItem {
|
|
||||||
display: block;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navItem:hover { background: rgba(255, 255, 255, .06); }
|
|
||||||
.navItem.active { background: var(--accent); color: white; font-weight: 600; }
|
|
||||||
|
|
||||||
.sidebarLogout { margin-top: 12px; padding: 8px 12px; }
|
|
||||||
|
|
||||||
.mainContent {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
padding: 24px clamp(16px, 3vw, 48px) 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sidebar becomes a horizontal scrollable topbar on narrow screens - same
|
|
||||||
"scroll instead of wrap" treatment as .tabs below. */
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.appShell { flex-direction: column; }
|
|
||||||
.sidebar {
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 12px;
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: hidden;
|
|
||||||
}
|
|
||||||
.sidebarBrand { padding: 0 12px 0 0; }
|
|
||||||
.sidebarNav { flex-direction: row; flex: none; }
|
|
||||||
.navSectionLabel { display: none; }
|
|
||||||
.sidebarLogout { margin: 0 0 0 12px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.pageHeader {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.joinInline { display: flex; gap: 8px; align-items: center; }
|
|
||||||
.joinInline input { width: 140px; margin: 0; }
|
|
||||||
.joinInline button { padding: 10px 14px; }
|
|
||||||
|
|
||||||
.section { margin-bottom: 32px; }
|
|
||||||
|
|
||||||
/* ---- Horizontal carousels (home page) ---- */
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
/* Tabs: one per Emby library (library.js) or per season (series.js). */
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
/* ---- Tile grid (library.js) + tiles (shared with the carousels) ---- */
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 20px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
/* Without this, a long title (or an image's intrinsic size before it
|
|
||||||
loads) can force this flex/grid item wider than its declared size -
|
|
||||||
the classic "min-width: auto" flexbox/grid pitfall. Every tile must
|
|
||||||
stay exactly the track/flex-basis size regardless of content. */
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile img {
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 2 / 3;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--panel);
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile img.noImage { visibility: hidden; }
|
|
||||||
|
|
||||||
.tileLink {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 0 26px 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tileLabel {
|
|
||||||
font-size: 13px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hover-reveal overlay with the two actions, on devices that actually have
|
|
||||||
hover (mouse/trackpad). Touch devices get an always-visible bar instead -
|
|
||||||
see the (hover: none) block below, since :hover is unreachable there. */
|
|
||||||
.tileOverlay {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 0 26px 0;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
background: rgba(0, 0, 0, .55);
|
|
||||||
border-radius: 8px;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity .15s ease;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tile:hover .tileOverlay,
|
|
||||||
.tile:focus-within .tileOverlay {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Base look is the same solid, high-contrast treatment as every other
|
|
||||||
button in the app (see the global `button {}` rule above) - but that
|
|
||||||
rule is tag-scoped, so it never applied to "Voir les épisodes", the one
|
|
||||||
.tileBtn that's an <a>, not a <button> (a plain hash link, so the
|
|
||||||
browser's native navigation handles it with no JS needed). Left
|
|
||||||
unstyled beyond padding/text-align, it rendered as a bare, small,
|
|
||||||
default-blue link sitting on the overlay - much less visible than
|
|
||||||
"Lancer une watch party" (a real <button>, styled by that global rule)
|
|
||||||
or "Voir sur Emby" (an <a>, but with the explicit .tileBtnLink look
|
|
||||||
below) right next to it. Styling .tileBtn itself, tag-agnostic, fixes
|
|
||||||
that - and is the semantically right call besides: for a Series tile,
|
|
||||||
"Voir les épisodes" IS the primary action (a Series is never itself
|
|
||||||
directly playable - see WATCH_PARTY_TYPES in server/src/index.js - so
|
|
||||||
the two never appear on the same tile), not a secondary one. */
|
|
||||||
.tileBtn {
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-align: center;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tileBtn:hover { background: var(--accent-bright); }
|
|
||||||
|
|
||||||
/* "Voir sur Emby" only: the one secondary/neutral action among the tile
|
|
||||||
buttons - overrides just the two properties that make it read as
|
|
||||||
secondary, on top of .tileBtn's shared sizing/weight/radius above. */
|
|
||||||
.tileBtnLink {
|
|
||||||
background: var(--panel);
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tileBtnLink:hover { background: #333; }
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
.tileOverlay {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
inset: auto 0 26px 0;
|
|
||||||
flex-direction: row;
|
|
||||||
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
|
|
||||||
padding: 6px;
|
|
||||||
}
|
|
||||||
.tileBtn { font-size: 11px; padding: 6px 4px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 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; }
|
|
||||||
|
|
||||||
.seriesInfo {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seriesYear { color: var(--muted); font-weight: 400; }
|
|
||||||
|
|
||||||
.seriesOverview {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0;
|
|
||||||
max-width: 640px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.seriesHeader { flex-direction: column; }
|
|
||||||
.seriesPoster { width: 120px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.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%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Music: album page, track lists (shared with the music player's own
|
|
||||||
queue/browse panel), now-playing header ---- */
|
|
||||||
|
|
||||||
.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; }
|
|
||||||
|
|
||||||
.trackList {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trackRow {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 28px 1fr auto auto;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
background: var(--bg-header);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
|
|
||||||
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
||||||
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.albumHeader { flex-direction: column; }
|
|
||||||
.albumCover { width: 120px; }
|
|
||||||
.trackRow { grid-template-columns: 24px 1fr; }
|
|
||||||
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Music player's "now playing" header - see musicPlayer.js. 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. */
|
|
||||||
.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 reused from the video player (same .avatar component -
|
|
||||||
see player.js) but in normal page flow here, not absolutely positioned
|
|
||||||
over a video frame. */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- Player ---- */
|
|
||||||
|
|
||||||
.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. 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 <p>'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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Presence overlay: one circle per party member. Lives inside .topOverlay,
|
|
||||||
right below the action bar - see renderMembers() in player.js. */
|
|
||||||
.presenceOverlay {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
position: relative; /* anchors .avatarPauseBadge */
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: white;
|
|
||||||
opacity: .75; /* "overlay en semi transparence" - background color comes from inline style, see colorForName() in player.js */
|
|
||||||
border: 2px solid rgba(255, 255, 255, .3);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Only the viewer's own avatar gets this, and only while not host - see
|
|
||||||
renderMembers(). 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.js. 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hide { display: none; }
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import { route, start, navigate } from './router.js';
|
|
||||||
import { auth, restoreSession } from './embyApi.js';
|
|
||||||
import { renderShell } from './shell.js';
|
|
||||||
import { renderLogin } from './views/login.js';
|
|
||||||
import { renderHome } from './views/home.js';
|
|
||||||
import { renderLibrary } from './views/library.js';
|
|
||||||
import { renderSeries } from './views/series.js';
|
|
||||||
import { renderArtist } from './views/artist.js';
|
|
||||||
import { renderAlbum } from './views/album.js';
|
|
||||||
import { renderPartyPlayer } from './views/player.js';
|
|
||||||
|
|
||||||
route('/', () => navigate('/home'));
|
|
||||||
route('/login', renderLogin);
|
|
||||||
route('/home', renderHome);
|
|
||||||
route('/library/:id', renderLibrary);
|
|
||||||
route('/series/:id', renderSeries);
|
|
||||||
route('/artist/:id', renderArtist);
|
|
||||||
route('/album/:id', renderAlbum);
|
|
||||||
route('/party/:id', renderPartyPlayer);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders either the bare login screen or the full shell (sidebar + routed
|
|
||||||
* content), depending on whether our session cookie is still valid. Exported
|
|
||||||
* so login.js can call it again right after a successful sign-in - there's
|
|
||||||
* no shell/router running yet at that point, just the login form.
|
|
||||||
*/
|
|
||||||
export async function boot() {
|
|
||||||
const appEl = document.getElementById('app');
|
|
||||||
const loggedIn = auth.isLoggedIn || await restoreSession();
|
|
||||||
|
|
||||||
if (!loggedIn) {
|
|
||||||
appEl.innerHTML = '';
|
|
||||||
renderLogin(appEl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mainContent = await renderShell(appEl);
|
|
||||||
start(mainContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
boot();
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
// Talks ONLY to our own backend now - never directly to Emby. The backend
|
|
||||||
// holds the real Emby session (server/src/embyAuth.js); this module just
|
|
||||||
// wraps fetch calls to our own /api/* routes. See docs/architecture.md for
|
|
||||||
// why this changed from the first version (CORS, plus not wanting the
|
|
||||||
// browser to hold an Emby token at all).
|
|
||||||
|
|
||||||
async function apiFetch(path, options = {}) {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
credentials: 'same-origin',
|
|
||||||
...options,
|
|
||||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export const auth = {
|
|
||||||
userName: null,
|
|
||||||
get isLoggedIn() { return !!this.userName; }
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Call once on app start to find out if our session cookie is still valid. */
|
|
||||||
export async function restoreSession() {
|
|
||||||
try {
|
|
||||||
const data = await apiFetch('/api/auth/me');
|
|
||||||
auth.userName = data.userName;
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
auth.userName = null;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(username, password) {
|
|
||||||
const data = await apiFetch('/api/auth/login', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ username, password })
|
|
||||||
});
|
|
||||||
auth.userName = data.userName;
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logout() {
|
|
||||||
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
|
||||||
auth.userName = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function searchItems(query) {
|
|
||||||
if (!query || query.trim().length < 2) return [];
|
|
||||||
const data = await apiFetch(`/api/catalog/search?q=${encodeURIComponent(query)}`);
|
|
||||||
return data.items || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getItem(itemId) {
|
|
||||||
return apiFetch(`/api/catalog/item/${itemId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLibraries() {
|
|
||||||
const data = await apiFetch('/api/catalog/libraries');
|
|
||||||
return data.libraries || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLibraryItems(libraryId) {
|
|
||||||
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/items`);
|
|
||||||
return data.items || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getRecentItems(libraryId) {
|
|
||||||
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/recent`);
|
|
||||||
return data.items || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSeriesSeasons(seriesId) {
|
|
||||||
const data = await apiFetch(`/api/catalog/series/${seriesId}/seasons`);
|
|
||||||
return data.seasons || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSeriesEpisodes(seriesId, seasonId) {
|
|
||||||
const data = await apiFetch(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
|
|
||||||
return data.episodes || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArtists(libraryId) {
|
|
||||||
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/artists`);
|
|
||||||
return data.artists || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getArtistAlbums(artistId) {
|
|
||||||
const data = await apiFetch(`/api/catalog/artists/${artistId}/albums`);
|
|
||||||
return data.albums || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAlbumTracks(albumId) {
|
|
||||||
const data = await apiFetch(`/api/catalog/albums/${albumId}/tracks`);
|
|
||||||
return data.tracks || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPlaybackOptions(itemId) {
|
|
||||||
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */
|
|
||||||
export function streamUrl(itemId, options = {}) {
|
|
||||||
const qs = new URLSearchParams(
|
|
||||||
Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
|
|
||||||
).toString();
|
|
||||||
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same stream, tagged for a music track - see server/src/index.js's
|
|
||||||
* /master.m3u8 route for why mediaType is the one thing that matters here
|
|
||||||
* (its hand-built-fallback path is genuinely different for audio). */
|
|
||||||
export function audioStreamUrl(itemId) {
|
|
||||||
return streamUrl(itemId, { mediaType: 'audio' });
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
// Party REST + realtime - same origin as everything else, auth is the
|
|
||||||
// session cookie (see embyApi.js), so no headers/tokens to pass around here.
|
|
||||||
// Realtime is Socket.IO (window.io, loaded via <script> in index.html) -
|
|
||||||
// gives us automatic reconnection and, on the server side, heartbeat-based
|
|
||||||
// dead-connection detection for free (see server/src/socketHandler.js).
|
|
||||||
//
|
|
||||||
// No join codes: joining happens straight from the home page's live list
|
|
||||||
// (connectLobby below) by party id - see docs/architecture.md.
|
|
||||||
|
|
||||||
async function apiFetch(path, options = {}) {
|
|
||||||
const res = await fetch(path, {
|
|
||||||
credentials: 'same-origin',
|
|
||||||
...options,
|
|
||||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
const body = await res.json().catch(() => ({}));
|
|
||||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createParty(itemId) {
|
|
||||||
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function joinParty(partyId) {
|
|
||||||
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function leaveParty(partyId) {
|
|
||||||
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getParty(partyId) {
|
|
||||||
return apiFetch(`/api/parties/${partyId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Music only: adds a track to a party's shared queue. REST, not a socket
|
|
||||||
* event, deliberately - unlike voteSkip/setMusicPaused below, this needs to
|
|
||||||
* work from the album-browsing page (album.js), which has no live
|
|
||||||
* connection to the party it might be adding into, only the player view
|
|
||||||
* (musicPlayer.js) does. `track` is whatever toItemDto shape the catalog
|
|
||||||
* endpoints already returned (itemId/itemName/artistName/albumName/
|
|
||||||
* itemImageUrl/durationTicks) - see server/src/musicQueueService.js.
|
|
||||||
*/
|
|
||||||
export function addTrackToQueue(partyId, track) {
|
|
||||||
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live "watch parties en cours" feed for the home page. `onParties` gets
|
|
||||||
* called with the full current list on subscribe, and again every time any
|
|
||||||
* party anywhere changes (created, someone joined/left, host handed off).
|
|
||||||
* Caller MUST call .disconnect() when navigating away (see home.js's router
|
|
||||||
* cleanup) - same leak-prevention pattern as connectParty below.
|
|
||||||
*/
|
|
||||||
export function connectLobby(onParties) {
|
|
||||||
const socket = window.io();
|
|
||||||
socket.on('connect', () => socket.emit('subscribeLobby'));
|
|
||||||
socket.on('parties', onParties);
|
|
||||||
return {
|
|
||||||
disconnect() {
|
|
||||||
socket.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Opens the realtime connection for a party. Returns an object the caller
|
|
||||||
* MUST eventually call .leave() on (e.g. from a router cleanup function -
|
|
||||||
* see router.js/player.js) - otherwise the socket, and whatever it's
|
|
||||||
* keeping alive via closures, never gets released.
|
|
||||||
*/
|
|
||||||
export function connectParty(partyId, {
|
|
||||||
onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose
|
|
||||||
} = {}) {
|
|
||||||
const socket = window.io();
|
|
||||||
|
|
||||||
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
|
|
||||||
// also covers rejoining after a transient network drop, not just the
|
|
||||||
// first connection.
|
|
||||||
socket.on('connect', () => {
|
|
||||||
socket.emit('join', { partyId });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onMembers) socket.on('members', onMembers);
|
|
||||||
if (onPlaystate) socket.on('playstate', onPlaystate);
|
|
||||||
if (onItemChanged) socket.on('itemChanged', onItemChanged);
|
|
||||||
if (onHostChanged) socket.on('hostChanged', onHostChanged);
|
|
||||||
if (onPauseRequested) socket.on('pauseRequested', onPauseRequested);
|
|
||||||
if (onQueueUpdated) socket.on('queueUpdated', onQueueUpdated); // music parties only - see musicPlayer.js
|
|
||||||
if (onError) socket.on('errorMessage', (msg) => onError(msg.message));
|
|
||||||
if (onClose) socket.on('disconnect', onClose);
|
|
||||||
|
|
||||||
return {
|
|
||||||
sendPlaystate(isPaused, positionTicks) {
|
|
||||||
socket.emit('playstate', { isPaused, positionTicks });
|
|
||||||
},
|
|
||||||
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
|
||||||
next() {
|
|
||||||
socket.emit('next');
|
|
||||||
},
|
|
||||||
/** Asks the host (and tells everyone else) "please pause". Video only -
|
|
||||||
* a music party has no host to ask, see requestSkip/setMusicPaused. */
|
|
||||||
requestPause() {
|
|
||||||
socket.emit('requestPause');
|
|
||||||
},
|
|
||||||
/** Music only: vote to skip the current track - see musicQueueService.js
|
|
||||||
* on the server for the majority threshold. */
|
|
||||||
voteSkip() {
|
|
||||||
socket.emit('voteSkip');
|
|
||||||
},
|
|
||||||
/** Music only: no host check server-side, unlike sendPlaystate above. */
|
|
||||||
setMusicPaused(isPaused) {
|
|
||||||
socket.emit('musicPlaystate', { isPaused });
|
|
||||||
},
|
|
||||||
leave() {
|
|
||||||
socket.emit('leave');
|
|
||||||
socket.disconnect();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
// Tiny hash router - no dependency, matches the project's "no build step"
|
|
||||||
// bias. Routes are registered with route(pattern, handler); handler gets
|
|
||||||
// (root, params) where params come from ":name" segments in the pattern.
|
|
||||||
//
|
|
||||||
// A handler may return a cleanup function. If it does, the router calls it
|
|
||||||
// right before rendering whatever comes next - this is what stops a view
|
|
||||||
// like the player (setInterval heartbeat, a realtime connection, an hls.js
|
|
||||||
// instance) from leaking when the user navigates away some way other than
|
|
||||||
// its own "Quitter" button (a sidebar link, browser back, ...). Before this
|
|
||||||
// existed, `currentRoot.innerHTML = ''` only ever destroyed DOM nodes -
|
|
||||||
// nothing told the outgoing view's JS to stop running.
|
|
||||||
|
|
||||||
const routes = [];
|
|
||||||
let currentRoot = null;
|
|
||||||
let listenerAttached = false;
|
|
||||||
let currentCleanup = null;
|
|
||||||
|
|
||||||
export function route(pattern, handler) {
|
|
||||||
const keys = [];
|
|
||||||
const regexSource = pattern
|
|
||||||
.split('/')
|
|
||||||
.map((segment) => {
|
|
||||||
if (segment.startsWith(':')) {
|
|
||||||
keys.push(segment.slice(1));
|
|
||||||
return '([^/]+)';
|
|
||||||
}
|
|
||||||
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
})
|
|
||||||
.join('/');
|
|
||||||
routes.push({ regex: new RegExp('^' + regexSource + '$'), keys, handler });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function navigate(path) {
|
|
||||||
location.hash = '#' + path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
if (!currentRoot) return;
|
|
||||||
|
|
||||||
if (typeof currentCleanup === 'function') {
|
|
||||||
try {
|
|
||||||
currentCleanup();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[watchparty] view cleanup failed', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentCleanup = null;
|
|
||||||
|
|
||||||
const [path, query] = (location.hash.slice(1) || '/').split('?');
|
|
||||||
const params = Object.fromEntries(new URLSearchParams(query || ''));
|
|
||||||
for (const r of routes) {
|
|
||||||
const match = path.match(r.regex);
|
|
||||||
if (match) {
|
|
||||||
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
|
|
||||||
currentRoot.innerHTML = '';
|
|
||||||
currentCleanup = r.handler(currentRoot, params);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
currentRoot.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (Re)targets the router at `root` and renders the current route into it.
|
|
||||||
* Safe to call more than once (e.g. after a logout/login cycle re-renders
|
|
||||||
* the app shell) - the hashchange listener is only ever attached once, so
|
|
||||||
* repeated calls don't pile up duplicate listeners.
|
|
||||||
*/
|
|
||||||
export function start(root) {
|
|
||||||
currentRoot = root;
|
|
||||||
if (!listenerAttached) {
|
|
||||||
window.addEventListener('hashchange', render);
|
|
||||||
listenerAttached = true;
|
|
||||||
}
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
// The persistent app chrome (sidebar with collections + main content area),
|
|
||||||
// rendered once per login. The router (router.js) only ever swaps what's
|
|
||||||
// inside #mainContent - see app.js's boot().
|
|
||||||
import { auth, logout, getLibraries } from './embyApi.js';
|
|
||||||
|
|
||||||
export async function renderShell(appEl) {
|
|
||||||
appEl.innerHTML = `
|
|
||||||
<div class="appShell">
|
|
||||||
<aside class="sidebar">
|
|
||||||
<div class="sidebarBrand">Watch Party</div>
|
|
||||||
<nav class="sidebarNav" id="sidebarNav">
|
|
||||||
<a href="#/home" class="navItem">Accueil</a>
|
|
||||||
<div class="navSectionLabel">Bibliothèques</div>
|
|
||||||
<p class="hint" id="libStatus">Chargement...</p>
|
|
||||||
</nav>
|
|
||||||
<button class="linklike sidebarLogout" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
|
|
||||||
</aside>
|
|
||||||
<main id="mainContent" class="mainContent"></main>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
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 (err) {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { getItem, getAlbumTracks } from '../embyApi.js';
|
|
||||||
import { createParty } from '../party.js';
|
|
||||||
import { navigate } from '../router.js';
|
|
||||||
import { escapeHtml, formatDuration } from '../tiles.js';
|
|
||||||
|
|
||||||
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 series.js's
|
|
||||||
* episode list, but flat - an album has no further sub-level the way a
|
|
||||||
* series' seasons do). */
|
|
||||||
export async function renderAlbum(root, params) {
|
|
||||||
const albumId = params.id;
|
|
||||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
|
||||||
|
|
||||||
let album;
|
|
||||||
let tracks;
|
|
||||||
try {
|
|
||||||
[album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]);
|
|
||||||
} catch (err) {
|
|
||||||
root.innerHTML = '<p class="error">Impossible de charger cet album.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<button class="linklike" id="back">← Retour</button>
|
|
||||||
<div class="albumHeader">
|
|
||||||
<img class="albumCover" src="${album.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
|
||||||
<div class="seriesInfo">
|
|
||||||
<h1>${escapeHtml(album.name)}${album.year ? ` <span class="seriesYear">(${album.year})</span>` : ''}</h1>
|
|
||||||
${album.artistName ? `<p class="hint">${escapeHtml(album.artistName)}</p>` : ''}
|
|
||||||
<a class="linklike" href="${album.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="trackList" class="trackList"></div>`;
|
|
||||||
|
|
||||||
root.querySelector('#back').addEventListener('click', () => history.back());
|
|
||||||
|
|
||||||
const listEl = root.querySelector('#trackList');
|
|
||||||
if (!tracks.length) {
|
|
||||||
listEl.innerHTML = '<p class="hint">Aucun morceau dans cet album.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
listEl.innerHTML = tracks.map((t) => `
|
|
||||||
<div class="trackRow">
|
|
||||||
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
|
|
||||||
<span class="trackName">${escapeHtml(t.name)}</span>
|
|
||||||
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
|
|
||||||
<button class="startPartyBtn" data-item-id="${t.id}">Écouter ensemble</button>
|
|
||||||
</div>
|
|
||||||
`).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);
|
|
||||||
navigate(`/party/${party.partyId}`);
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Écouter ensemble';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import { getItem, getArtistAlbums } from '../embyApi.js';
|
|
||||||
import { tileHtml, escapeHtml } from '../tiles.js';
|
|
||||||
|
|
||||||
/** Route `/artist/:id` - one artist's albums, reached from a music
|
|
||||||
* library's top-level grid (see library.js) via a MusicArtist tile's
|
|
||||||
* "Voir les albums" link (tiles.js's DRILL_DOWN table). Structurally the
|
|
||||||
* same "detail header + grid" shape as series.js, 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, params) {
|
|
||||||
const artistId = params.id;
|
|
||||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
|
||||||
|
|
||||||
let artist;
|
|
||||||
let albums;
|
|
||||||
try {
|
|
||||||
[artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]);
|
|
||||||
} catch (err) {
|
|
||||||
root.innerHTML = '<p class="error">Impossible de charger cet artiste.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<button class="linklike" id="back">← Retour</button>
|
|
||||||
<div class="pageHeader"><h1>${escapeHtml(artist.name)}</h1></div>
|
|
||||||
<div id="grid" class="grid"></div>`;
|
|
||||||
|
|
||||||
root.querySelector('#back').addEventListener('click', () => history.back());
|
|
||||||
|
|
||||||
const grid = root.querySelector('#grid');
|
|
||||||
grid.innerHTML = albums.length
|
|
||||||
? albums.map(tileHtml).join('')
|
|
||||||
: '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { login } from '../embyApi.js';
|
|
||||||
import { boot } from '../app.js';
|
|
||||||
|
|
||||||
export function renderLogin(root) {
|
|
||||||
root.innerHTML = `
|
|
||||||
<main class="wrap">
|
|
||||||
<h1>Watch Party</h1>
|
|
||||||
<form id="loginForm" class="panel">
|
|
||||||
<p class="hint">Utilise ton compte Emby habituel.</p>
|
|
||||||
<label>Nom d'utilisateur
|
|
||||||
<input id="username" autocomplete="username" required autofocus />
|
|
||||||
</label>
|
|
||||||
<label>Mot de passe
|
|
||||||
<input id="password" type="password" autocomplete="current-password" required />
|
|
||||||
</label>
|
|
||||||
<button type="submit">Se connecter</button>
|
|
||||||
<p id="error" class="error"></p>
|
|
||||||
</form>
|
|
||||||
</main>`;
|
|
||||||
|
|
||||||
const form = root.querySelector('#loginForm');
|
|
||||||
form.addEventListener('submit', async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const errorEl = form.querySelector('#error');
|
|
||||||
errorEl.textContent = '';
|
|
||||||
try {
|
|
||||||
await login(form.querySelector('#username').value, form.querySelector('#password').value);
|
|
||||||
await boot(); // no shell/router running yet at the login screen - (re)boot into it now
|
|
||||||
} catch (err) {
|
|
||||||
errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe.";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import { getItem, getSeriesSeasons, getSeriesEpisodes } from '../embyApi.js';
|
|
||||||
import { createParty } from '../party.js';
|
|
||||||
import { navigate } from '../router.js';
|
|
||||||
import { escapeHtml } from '../tiles.js';
|
|
||||||
|
|
||||||
export async function renderSeries(root, params) {
|
|
||||||
const seriesId = params.id;
|
|
||||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
|
||||||
|
|
||||||
let series;
|
|
||||||
let seasons;
|
|
||||||
try {
|
|
||||||
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
|
|
||||||
} catch (err) {
|
|
||||||
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
root.innerHTML = `
|
|
||||||
<button class="linklike" id="back">← Retour</button>
|
|
||||||
<div class="seriesHeader">
|
|
||||||
<img class="seriesPoster" src="${series.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
|
||||||
<div class="seriesInfo">
|
|
||||||
<h1>${escapeHtml(series.name)}${series.year ? ` <span class="seriesYear">(${series.year})</span>` : ''}</h1>
|
|
||||||
${series.overview ? `<p class="seriesOverview">${escapeHtml(series.overview)}</p>` : ''}
|
|
||||||
<a class="linklike" href="${series.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<nav id="seasonTabs" class="tabs"></nav>
|
|
||||||
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>`;
|
|
||||||
|
|
||||||
root.querySelector('#back').addEventListener('click', () => history.back());
|
|
||||||
|
|
||||||
if (!seasons.length) {
|
|
||||||
root.querySelector('#episodeList').innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsEl = root.querySelector('#seasonTabs');
|
|
||||||
tabsEl.innerHTML = seasons
|
|
||||||
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
|
||||||
.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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
loadEpisodes(root, seriesId, seasons[0].id);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadEpisodes(root, seriesId, seasonId) {
|
|
||||||
const listEl = root.querySelector('#episodeList');
|
|
||||||
listEl.innerHTML = '<p class="hint">Chargement...</p>';
|
|
||||||
|
|
||||||
let episodes;
|
|
||||||
try {
|
|
||||||
episodes = await getSeriesEpisodes(seriesId, seasonId);
|
|
||||||
} catch (err) {
|
|
||||||
listEl.innerHTML = '<p class="error">Impossible de charger les épisodes.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!episodes.length) {
|
|
||||||
listEl.innerHTML = '<p class="hint">Aucun épisode dans cette saison.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starting a party from any episode here queues the rest of the series
|
|
||||||
// from that point on server-side (see server/src/index.js) - the host can
|
|
||||||
// then use "Épisode suivant" in the player instead of restarting per episode.
|
|
||||||
listEl.innerHTML = episodes.map((ep) => `
|
|
||||||
<div class="episodeRow">
|
|
||||||
<img src="${ep.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
|
||||||
<div class="episodeInfo">
|
|
||||||
<strong>${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)}</strong>
|
|
||||||
<p class="hint">${escapeHtml(ep.overview || '')}</p>
|
|
||||||
</div>
|
|
||||||
<button class="startEpisodeBtn" data-item-id="${ep.id}">Lancer une watch party</button>
|
|
||||||
</div>
|
|
||||||
`).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);
|
|
||||||
navigate(`/party/${party.partyId}`);
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message);
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Lancer une watch party';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
478
client/package-lock.json
generated
Normal file
478
client/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,478 @@
|
||||||
|
{
|
||||||
|
"name": "watchparty-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "watchparty-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.23.0",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.23.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz",
|
||||||
|
"integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.23.1",
|
||||||
|
"@esbuild/android-arm": "0.23.1",
|
||||||
|
"@esbuild/android-arm64": "0.23.1",
|
||||||
|
"@esbuild/android-x64": "0.23.1",
|
||||||
|
"@esbuild/darwin-arm64": "0.23.1",
|
||||||
|
"@esbuild/darwin-x64": "0.23.1",
|
||||||
|
"@esbuild/freebsd-arm64": "0.23.1",
|
||||||
|
"@esbuild/freebsd-x64": "0.23.1",
|
||||||
|
"@esbuild/linux-arm": "0.23.1",
|
||||||
|
"@esbuild/linux-arm64": "0.23.1",
|
||||||
|
"@esbuild/linux-ia32": "0.23.1",
|
||||||
|
"@esbuild/linux-loong64": "0.23.1",
|
||||||
|
"@esbuild/linux-mips64el": "0.23.1",
|
||||||
|
"@esbuild/linux-ppc64": "0.23.1",
|
||||||
|
"@esbuild/linux-riscv64": "0.23.1",
|
||||||
|
"@esbuild/linux-s390x": "0.23.1",
|
||||||
|
"@esbuild/linux-x64": "0.23.1",
|
||||||
|
"@esbuild/netbsd-x64": "0.23.1",
|
||||||
|
"@esbuild/openbsd-arm64": "0.23.1",
|
||||||
|
"@esbuild/openbsd-x64": "0.23.1",
|
||||||
|
"@esbuild/sunos-x64": "0.23.1",
|
||||||
|
"@esbuild/win32-arm64": "0.23.1",
|
||||||
|
"@esbuild/win32-ia32": "0.23.1",
|
||||||
|
"@esbuild/win32-x64": "0.23.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
client/package.json
Normal file
14
client/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "watchparty-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"watch": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"esbuild": "^0.23.0",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
123
client/src/api/embyApi.ts
Normal file
123
client/src/api/embyApi.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
// Talks ONLY to our own backend now - never directly to Emby. The backend
|
||||||
|
// holds the real Emby session (server/src/services/AuthService.ts); this
|
||||||
|
// module just wraps fetch calls to our own /api/* routes. See
|
||||||
|
// docs/architecture.md for why this changed from the first version (CORS,
|
||||||
|
// plus not wanting the browser to hold an Emby token at all).
|
||||||
|
import type { ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthState {
|
||||||
|
userName: string | null = null;
|
||||||
|
get isLoggedIn(): boolean {
|
||||||
|
return !!this.userName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const auth = new AuthState();
|
||||||
|
|
||||||
|
/** Call once on app start to find out if our session cookie is still valid. */
|
||||||
|
export async function restoreSession(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch<{ userName: string }>('/api/auth/me');
|
||||||
|
auth.userName = data.userName;
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
auth.userName = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(username: string, password: string): Promise<{ userName: string }> {
|
||||||
|
const data = await apiFetch<{ userName: string }>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
auth.userName = data.userName;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await apiFetch('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
||||||
|
auth.userName = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchItems(query: string): Promise<ItemDto[]> {
|
||||||
|
if (!query || query.trim().length < 2) return [];
|
||||||
|
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/search?q=${encodeURIComponent(query)}`);
|
||||||
|
return data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getItem(itemId: string): Promise<ItemDto> {
|
||||||
|
return apiFetch(`/api/catalog/item/${itemId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLibraries(): Promise<LibraryDto[]> {
|
||||||
|
const data = await apiFetch<{ libraries: LibraryDto[] }>('/api/catalog/libraries');
|
||||||
|
return data.libraries || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLibraryItems(libraryId: string): Promise<ItemDto[]> {
|
||||||
|
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/items`);
|
||||||
|
return data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecentItems(libraryId: string): Promise<ItemDto[]> {
|
||||||
|
const data = await apiFetch<{ items: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/recent`);
|
||||||
|
return data.items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSeriesSeasons(seriesId: string): Promise<SeasonDto[]> {
|
||||||
|
const data = await apiFetch<{ seasons: SeasonDto[] }>(`/api/catalog/series/${seriesId}/seasons`);
|
||||||
|
return data.seasons || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSeriesEpisodes(seriesId: string, seasonId: string): Promise<ItemDto[]> {
|
||||||
|
const data = await apiFetch<{ episodes: ItemDto[] }>(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
|
||||||
|
return data.episodes || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtists(libraryId: string): Promise<ItemDto[]> {
|
||||||
|
const data = await apiFetch<{ artists: ItemDto[] }>(`/api/catalog/libraries/${libraryId}/artists`);
|
||||||
|
return data.artists || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistAlbums(artistId: string): Promise<ItemDto[]> {
|
||||||
|
const data = await apiFetch<{ albums: ItemDto[] }>(`/api/catalog/artists/${artistId}/albums`);
|
||||||
|
return data.albums || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbumTracks(albumId: string): Promise<ItemDto[]> {
|
||||||
|
const data = await apiFetch<{ tracks: ItemDto[] }>(`/api/catalog/albums/${albumId}/tracks`);
|
||||||
|
return data.tracks || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaybackOptions(itemId: string): Promise<PlaybackOptionsDto> {
|
||||||
|
return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate/mediaType. */
|
||||||
|
export function streamUrl(itemId: string, options: Record<string, string | number | null | undefined> = {}): string {
|
||||||
|
const entries = Object.entries(options).filter(([, v]) => v !== '' && v != null) as [string, string][];
|
||||||
|
const qs = new URLSearchParams(entries).toString();
|
||||||
|
return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same stream, tagged for a music track - see server/src/services/
|
||||||
|
* CatalogService.ts's proxyStream for why mediaType is the one thing
|
||||||
|
* that matters here (its hand-built-fallback path is genuinely different
|
||||||
|
* for audio). */
|
||||||
|
export function audioStreamUrl(itemId: string): string {
|
||||||
|
return streamUrl(itemId, { mediaType: 'audio' });
|
||||||
|
}
|
||||||
145
client/src/api/party.ts
Normal file
145
client/src/api/party.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
// Party REST + realtime - same origin as everything else, auth is the
|
||||||
|
// session cookie (see embyApi.ts), so no headers/tokens to pass around here.
|
||||||
|
// Realtime is Socket.IO (the `io` global, loaded via <script> in
|
||||||
|
// index.html - see global.d.ts) - gives us automatic reconnection and, on
|
||||||
|
// the server side, heartbeat-based dead-connection detection for free (see
|
||||||
|
// server/src/realtime/SocketGateway.ts).
|
||||||
|
//
|
||||||
|
// No join codes: joining happens straight from the home page's live list
|
||||||
|
// (connectLobby below) by party id - see docs/architecture.md.
|
||||||
|
import type { ItemChangedMsg, LobbyPartyDto, MusicPublicState, MusicTrackInput, PartyPublicState, PauseRequestedMsg, SocketMember } from '../types';
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...options,
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createParty(itemId: string): Promise<PartyPublicState> {
|
||||||
|
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinParty(partyId: string): Promise<PartyPublicState> {
|
||||||
|
return apiFetch(`/api/parties/${encodeURIComponent(partyId)}/join`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveParty(partyId: string): Promise<{ ok: true }> {
|
||||||
|
return apiFetch(`/api/parties/${partyId}/leave`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParty(partyId: string): Promise<PartyPublicState> {
|
||||||
|
return apiFetch(`/api/parties/${partyId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Music only: adds a track to a party's shared queue. REST, not a socket
|
||||||
|
* event, deliberately - unlike voteSkip/setMusicPaused below, this needs
|
||||||
|
* to work from the album-browsing page and the music player's own inline
|
||||||
|
* browser, neither of which is guaranteed to be the page with the live
|
||||||
|
* connection to the party being added into. `track` is whatever ItemDto
|
||||||
|
* shape the catalog endpoints already returned - see
|
||||||
|
* server/src/services/MusicQueueService.ts.
|
||||||
|
*/
|
||||||
|
export function addTrackToQueue(partyId: string, track: MusicTrackInput): Promise<PartyPublicState> {
|
||||||
|
return apiFetch(`/api/parties/${partyId}/tracks`, { method: 'POST', body: JSON.stringify(track) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live "watch parties en cours" feed for the home page. `onParties` gets
|
||||||
|
* called with the full current list on subscribe, and again every time any
|
||||||
|
* party anywhere changes (created, someone joined/left, host handed off).
|
||||||
|
* Caller MUST call .disconnect() when navigating away (see home's router
|
||||||
|
* cleanup) - same leak-prevention pattern as connectParty below.
|
||||||
|
*/
|
||||||
|
export function connectLobby(onParties: (parties: LobbyPartyDto[]) => void): { disconnect(): void } {
|
||||||
|
const socket = io();
|
||||||
|
socket.on('connect', () => socket.emit('subscribeLobby'));
|
||||||
|
socket.on('parties', (parties) => onParties(parties as LobbyPartyDto[]));
|
||||||
|
return {
|
||||||
|
disconnect() {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyConnectionCallbacks {
|
||||||
|
onMembers?: (members: SocketMember[]) => void;
|
||||||
|
onPlaystate?: (msg: { isPaused: boolean; positionTicks: number }) => void;
|
||||||
|
onItemChanged?: (msg: ItemChangedMsg) => void;
|
||||||
|
onHostChanged?: () => void;
|
||||||
|
onPauseRequested?: (msg: PauseRequestedMsg) => void;
|
||||||
|
onQueueUpdated?: (state: MusicPublicState) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartyConnection {
|
||||||
|
sendPlaystate(isPaused: boolean, positionTicks: number): void;
|
||||||
|
/** Host-only: advance the party's queue (e.g. to the next episode). */
|
||||||
|
next(): void;
|
||||||
|
/** Asks the host (and tells everyone else) "please pause". Video only -
|
||||||
|
* a music party has no host to ask, see voteSkip/setMusicPaused. */
|
||||||
|
requestPause(): void;
|
||||||
|
/** Music only: vote to skip the current track - see MusicQueueService
|
||||||
|
* on the server for the majority threshold. */
|
||||||
|
voteSkip(): void;
|
||||||
|
/** Music only: no host check server-side, unlike sendPlaystate above. */
|
||||||
|
setMusicPaused(isPaused: boolean): void;
|
||||||
|
leave(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the realtime connection for a party. Returns an object the caller
|
||||||
|
* MUST eventually call .leave() on (e.g. from a router cleanup function -
|
||||||
|
* see router.ts/player) - otherwise the socket, and whatever it's keeping
|
||||||
|
* alive via closures, never gets released.
|
||||||
|
*/
|
||||||
|
export function connectParty(partyId: string, callbacks: PartyConnectionCallbacks = {}): PartyConnection {
|
||||||
|
const { onMembers, onPlaystate, onItemChanged, onHostChanged, onPauseRequested, onQueueUpdated, onError, onClose } = callbacks;
|
||||||
|
const socket = io();
|
||||||
|
|
||||||
|
// Reconnects (Socket.IO retries automatically) re-fire "connect", so this
|
||||||
|
// also covers rejoining after a transient network drop, not just the
|
||||||
|
// first connection.
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.emit('join', { partyId });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (onMembers) socket.on('members', (m) => onMembers(m as SocketMember[]));
|
||||||
|
if (onPlaystate) socket.on('playstate', (m) => onPlaystate(m as { isPaused: boolean; positionTicks: number }));
|
||||||
|
if (onItemChanged) socket.on('itemChanged', (m) => onItemChanged(m as ItemChangedMsg));
|
||||||
|
if (onHostChanged) socket.on('hostChanged', () => onHostChanged());
|
||||||
|
if (onPauseRequested) socket.on('pauseRequested', (m) => onPauseRequested(m as PauseRequestedMsg));
|
||||||
|
if (onQueueUpdated) socket.on('queueUpdated', (m) => onQueueUpdated(m as MusicPublicState)); // music parties only - see MusicPlayer
|
||||||
|
if (onError) socket.on('errorMessage', (m) => onError((m as { message: string }).message));
|
||||||
|
if (onClose) socket.on('disconnect', () => onClose());
|
||||||
|
|
||||||
|
return {
|
||||||
|
sendPlaystate(isPaused, positionTicks) {
|
||||||
|
socket.emit('playstate', { isPaused, positionTicks });
|
||||||
|
},
|
||||||
|
next() {
|
||||||
|
socket.emit('next');
|
||||||
|
},
|
||||||
|
requestPause() {
|
||||||
|
socket.emit('requestPause');
|
||||||
|
},
|
||||||
|
voteSkip() {
|
||||||
|
socket.emit('voteSkip');
|
||||||
|
},
|
||||||
|
setMusicPaused(isPaused) {
|
||||||
|
socket.emit('musicPlaystate', { isPaused });
|
||||||
|
},
|
||||||
|
leave() {
|
||||||
|
socket.emit('leave');
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
43
client/src/app.ts
Normal file
43
client/src/app.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
import './styles/base.css';
|
||||||
|
import { route, start, navigate } from './router';
|
||||||
|
import { auth, restoreSession } from './api/embyApi';
|
||||||
|
import { renderShell } from './shell/Shell';
|
||||||
|
import { renderLogin } from './views/login/Login';
|
||||||
|
import { renderHome } from './views/home/Home';
|
||||||
|
import { renderLibrary } from './views/library/Library';
|
||||||
|
import { renderSeries } from './views/series/Series';
|
||||||
|
import { renderArtist } from './views/artist/Artist';
|
||||||
|
import { renderAlbum } from './views/album/Album';
|
||||||
|
import { renderPartyPlayer } from './views/player/Player';
|
||||||
|
|
||||||
|
route('/', () => navigate('/home'));
|
||||||
|
route('/login', renderLogin);
|
||||||
|
route('/home', renderHome);
|
||||||
|
route('/library/:id', renderLibrary);
|
||||||
|
route('/series/:id', renderSeries);
|
||||||
|
route('/artist/:id', renderArtist);
|
||||||
|
route('/album/:id', renderAlbum);
|
||||||
|
route('/party/:id', renderPartyPlayer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders either the bare login screen or the full shell (sidebar +
|
||||||
|
* routed content), depending on whether our session cookie is still
|
||||||
|
* valid. Exported so Login.ts can call it again right after a successful
|
||||||
|
* sign-in - there's no shell/router running yet at that point, just the
|
||||||
|
* login form.
|
||||||
|
*/
|
||||||
|
export async function boot(): Promise<void> {
|
||||||
|
const appEl = document.getElementById('app') as HTMLElement;
|
||||||
|
const loggedIn = auth.isLoggedIn || await restoreSession();
|
||||||
|
|
||||||
|
if (!loggedIn) {
|
||||||
|
appEl.innerHTML = '';
|
||||||
|
renderLogin(appEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainContent = await renderShell(appEl);
|
||||||
|
start(mainContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
59
client/src/global.d.ts
vendored
Normal file
59
client/src/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
/**
|
||||||
|
* Ambient declarations for things esbuild/the browser provide that aren't
|
||||||
|
* npm packages - see client/build.mjs's `.html`-as-text loader, and
|
||||||
|
* client/index.html's CDN <script> tags for hls.js/socket.io (both used
|
||||||
|
* as plain globals, not imported - keeping the "pin an exact CDN version,
|
||||||
|
* no bundler-managed copy" choice this app made from the start).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Each component's own .html template, imported as a raw string - see
|
||||||
|
* any view's own .ts file for the pattern (`import html from './Foo.html'`). */
|
||||||
|
declare module '*.html' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Imported purely for esbuild's CSS bundling side effect (see any view's
|
||||||
|
* own `import './Foo.css'`) - there's nothing to type, the import itself
|
||||||
|
* is the whole point. */
|
||||||
|
declare module '*.css';
|
||||||
|
|
||||||
|
// --- socket.io-client (CDN global, see client/index.html) -------------------
|
||||||
|
// Minimal shape covering only what this app actually calls - not the full
|
||||||
|
// socket.io-client API surface.
|
||||||
|
|
||||||
|
interface AppClientSocket {
|
||||||
|
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||||
|
emit(event: string, payload?: unknown): void;
|
||||||
|
disconnect(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare function io(): AppClientSocket;
|
||||||
|
|
||||||
|
// --- hls.js (CDN global, see client/index.html) -----------------------------
|
||||||
|
// Same idea - only the surface client/src/views/player and musicPlayer
|
||||||
|
// actually use.
|
||||||
|
|
||||||
|
interface HlsErrorData {
|
||||||
|
fatal: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HlsInstance {
|
||||||
|
on(event: string, handler: (event: string, data: HlsErrorData) => void): void;
|
||||||
|
loadSource(url: string): void;
|
||||||
|
attachMedia(media: HTMLMediaElement): void;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HlsStatic {
|
||||||
|
new (): HlsInstance;
|
||||||
|
isSupported(): boolean;
|
||||||
|
Events: { MANIFEST_PARSED: string; ERROR: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const Hls: HlsStatic | undefined;
|
||||||
|
|
||||||
|
interface Window {
|
||||||
|
Hls?: HlsStatic;
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,12 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Watch Party</title>
|
<title>Watch Party</title>
|
||||||
<link rel="stylesheet" href="css/app.css" />
|
<link rel="stylesheet" href="app.css" />
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="js/app.js"></script>
|
<script type="module" src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
105
client/src/router.ts
Normal file
105
client/src/router.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// Tiny hash router - no dependency, matches the project's "no build step
|
||||||
|
// for the client's actual routing logic" bias even now that there IS a
|
||||||
|
// build step for TypeScript itself. Routes are registered with
|
||||||
|
// route(pattern, handler); handler gets (root, params) where params come
|
||||||
|
// from ":name" segments in the pattern.
|
||||||
|
//
|
||||||
|
// A handler may return a cleanup function. If it does, the router calls it
|
||||||
|
// right before rendering whatever comes next - this is what stops a view
|
||||||
|
// like the player (setInterval heartbeat, a realtime connection, an hls.js
|
||||||
|
// instance) from leaking when the user navigates away some way other than
|
||||||
|
// its own "Quitter" button (a sidebar link, browser back, ...). Before this
|
||||||
|
// existed, `currentRoot.innerHTML = ''` only ever destroyed DOM nodes -
|
||||||
|
// nothing told the outgoing view's JS to stop running.
|
||||||
|
|
||||||
|
export type RouteParams = Record<string, string>;
|
||||||
|
export type CleanupFn = () => void;
|
||||||
|
/**
|
||||||
|
* A handler can be `async` (most views just fetch and render, with
|
||||||
|
* nothing to clean up - the resulting `Promise<void>` is simply not a
|
||||||
|
* function, so render() below leaves `currentCleanup` at null) or
|
||||||
|
* synchronous-returning-a-CleanupFn (a view that opens a socket/interval/
|
||||||
|
* hls.js instance and needs to release it - see views/player/Player.ts).
|
||||||
|
* What's deliberately NOT allowed here is `Promise<CleanupFn>`: a route
|
||||||
|
* that needs cleanup but fetches first has to return a real function
|
||||||
|
* *synchronously* regardless (see Player.ts's own dispatcher for exactly
|
||||||
|
* that pattern, and why - the type system won't stop a handler from doing
|
||||||
|
* `async () => { await x; return cleanup; }` instead, but it also won't
|
||||||
|
* paper over the mistake by pretending that shape is supported here.
|
||||||
|
*/
|
||||||
|
export type RouteHandler = (root: HTMLElement, params: RouteParams) => CleanupFn | void | Promise<void>;
|
||||||
|
|
||||||
|
interface CompiledRoute {
|
||||||
|
regex: RegExp;
|
||||||
|
keys: string[];
|
||||||
|
handler: RouteHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
const routes: CompiledRoute[] = [];
|
||||||
|
let currentRoot: HTMLElement | null = null;
|
||||||
|
let listenerAttached = false;
|
||||||
|
let currentCleanup: CleanupFn | null = null;
|
||||||
|
|
||||||
|
export function route(pattern: string, handler: RouteHandler): void {
|
||||||
|
const keys: string[] = [];
|
||||||
|
const regexSource = pattern
|
||||||
|
.split('/')
|
||||||
|
.map((segment) => {
|
||||||
|
if (segment.startsWith(':')) {
|
||||||
|
keys.push(segment.slice(1));
|
||||||
|
return '([^/]+)';
|
||||||
|
}
|
||||||
|
return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
})
|
||||||
|
.join('/');
|
||||||
|
routes.push({ regex: new RegExp('^' + regexSource + '$'), keys, handler });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigate(path: string): void {
|
||||||
|
location.hash = '#' + path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(): void {
|
||||||
|
if (!currentRoot) return;
|
||||||
|
|
||||||
|
if (typeof currentCleanup === 'function') {
|
||||||
|
try {
|
||||||
|
currentCleanup();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[watchparty] view cleanup failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentCleanup = null;
|
||||||
|
|
||||||
|
const [path, query] = (location.hash.slice(1) || '/').split('?');
|
||||||
|
const params: RouteParams = Object.fromEntries(new URLSearchParams(query || ''));
|
||||||
|
for (const r of routes) {
|
||||||
|
const match = (path || '').match(r.regex);
|
||||||
|
if (match) {
|
||||||
|
r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1] as string); });
|
||||||
|
currentRoot.innerHTML = '';
|
||||||
|
const result = r.handler(currentRoot, params);
|
||||||
|
// A Promise (an async handler with nothing to clean up) is
|
||||||
|
// correctly *not* a function - see RouteHandler's own comment for
|
||||||
|
// why that's the deliberately-supported "no cleanup" shape.
|
||||||
|
currentCleanup = typeof result === 'function' ? result : null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentRoot.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Re)targets the router at `root` and renders the current route into it.
|
||||||
|
* Safe to call more than once (e.g. after a logout/login cycle re-renders
|
||||||
|
* the app shell) - the hashchange listener is only ever attached once, so
|
||||||
|
* repeated calls don't pile up duplicate listeners.
|
||||||
|
*/
|
||||||
|
export function start(root: HTMLElement): void {
|
||||||
|
currentRoot = root;
|
||||||
|
if (!listenerAttached) {
|
||||||
|
window.addEventListener('hashchange', render);
|
||||||
|
listenerAttached = true;
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
81
client/src/shell/Shell.css
Normal file
81
client/src/shell/Shell.css
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
/* ---- App shell: sidebar + routed main content - see Shell.ts. ---- */
|
||||||
|
|
||||||
|
.appShell {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--bg-header);
|
||||||
|
padding: 20px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarBrand {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarNav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navSectionLabel {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .05em;
|
||||||
|
padding: 16px 12px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem {
|
||||||
|
display: block;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navItem:hover { background: rgba(255, 255, 255, .06); }
|
||||||
|
.navItem.active { background: var(--accent); color: white; font-weight: 600; }
|
||||||
|
|
||||||
|
.sidebarLogout { margin-top: 12px; padding: 8px 12px; }
|
||||||
|
|
||||||
|
.mainContent {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 24px clamp(16px, 3vw, 48px) 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar becomes a horizontal scrollable topbar on narrow screens. */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.appShell { flex-direction: column; }
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
.sidebarBrand { padding: 0 12px 0 0; }
|
||||||
|
.sidebarNav { flex-direction: row; flex: none; }
|
||||||
|
.navSectionLabel { display: none; }
|
||||||
|
.sidebarLogout { margin: 0 0 0 12px; }
|
||||||
|
}
|
||||||
12
client/src/shell/Shell.html
Normal file
12
client/src/shell/Shell.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<div class="appShell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="sidebarBrand">Watch Party</div>
|
||||||
|
<nav class="sidebarNav" id="sidebarNav">
|
||||||
|
<a href="#/home" class="navItem">Accueil</a>
|
||||||
|
<div class="navSectionLabel">Bibliothèques</div>
|
||||||
|
<p class="hint" id="libStatus">Chargement...</p>
|
||||||
|
</nav>
|
||||||
|
<button class="linklike sidebarLogout" id="logout"></button>
|
||||||
|
</aside>
|
||||||
|
<main id="mainContent" class="mainContent"></main>
|
||||||
|
</div>
|
||||||
46
client/src/shell/Shell.ts
Normal file
46
client/src/shell/Shell.ts
Normal file
|
|
@ -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<HTMLElement> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
122
client/src/styles/base.css
Normal file
122
client/src/styles/base.css
Normal file
|
|
@ -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 <a class="linklike"> - e.g. the
|
||||||
|
"Voir sur Emby" link on the series/album detail pages - gets the same
|
||||||
|
treatment as the <button> ones without needing its own duplicate rule;
|
||||||
|
there's no global `a` reset in this file, so an unclassed anchor would
|
||||||
|
otherwise fall back to the browser's default blue/underline against
|
||||||
|
this dark theme. */
|
||||||
|
.linklike {
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
text-decoration: underline;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linklike:hover { background: none; color: var(--accent-bright); }
|
||||||
|
|
||||||
|
.code { color: var(--accent-bright); letter-spacing: .1em; }
|
||||||
|
|
||||||
|
/* Generic "title + optional action on the right" header, reused by
|
||||||
|
home/library/artist/the music player - see each view's own .ts. */
|
||||||
|
.pageHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide { display: none; }
|
||||||
21
client/src/styles/detail.css
Normal file
21
client/src/styles/detail.css
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/* Shared "poster/cover + title + overview + Emby link" info block, used by
|
||||||
|
both views/series (a series' overview) and views/album (an album's
|
||||||
|
artist name) - see each's own Series.css/Album.css for the
|
||||||
|
poster/cover image sizing itself, which differs (2:3 vs 1:1). */
|
||||||
|
.seriesInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seriesYear { color: var(--muted); font-weight: 400; }
|
||||||
|
|
||||||
|
.seriesOverview {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
25
client/src/styles/presence.css
Normal file
25
client/src/styles/presence.css
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/* Shared base for the "who's here" circle, used by both the video player
|
||||||
|
(Player.css adds the click-to-request-pause states on top) and the
|
||||||
|
music player (which just shows presence, no per-avatar interaction -
|
||||||
|
see MusicPlayer.css's .musicPresence for its positioning tweak). */
|
||||||
|
.presenceOverlay {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
opacity: .75; /* "overlay en semi transparence" - background color comes from an inline style, see colorForName() in tiles.ts */
|
||||||
|
border: 2px solid rgba(255, 255, 255, .3);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
29
client/src/styles/trackList.css
Normal file
29
client/src/styles/trackList.css
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/* Shared track-list rows, used by both views/album (an album's tracks)
|
||||||
|
and views/musicPlayer (the current queue, and its inline browse panel's
|
||||||
|
own track lists). */
|
||||||
|
.trackList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackRow {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: var(--bg-header);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackIndex { color: var(--muted); font-size: 13px; text-align: center; }
|
||||||
|
.trackName { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.trackDuration { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||||
|
.trackRow button { flex-shrink: 0; padding: 6px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.trackRow { grid-template-columns: 24px 1fr; }
|
||||||
|
.trackRow .trackDuration, .trackRow button { grid-column: 2; justify-self: start; }
|
||||||
|
}
|
||||||
118
client/src/tiles.css
Normal file
118
client/src/tiles.css
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
/* ---- Tile grid (library/artist pages) + tiles (shared with the home
|
||||||
|
carousels) - see tiles.ts. ---- */
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 20px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
/* Without this, a long title (or an image's intrinsic size before it
|
||||||
|
loads) can force this flex/grid item wider than its declared size -
|
||||||
|
the classic "min-width: auto" flexbox/grid pitfall. Every tile must
|
||||||
|
stay exactly the track/flex-basis size regardless of content. */
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile img.noImage { visibility: hidden; }
|
||||||
|
|
||||||
|
.tileLink {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 26px 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover-reveal overlay with the two actions, on devices that actually have
|
||||||
|
hover (mouse/trackpad). Touch devices get an always-visible bar instead -
|
||||||
|
see the (hover: none) block below, since :hover is unreachable there. */
|
||||||
|
.tileOverlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 26px 0;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(0, 0, 0, .55);
|
||||||
|
border-radius: 8px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile:hover .tileOverlay,
|
||||||
|
.tile:focus-within .tileOverlay {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base look is the same solid, high-contrast treatment as every other
|
||||||
|
button in the app (see base.css's global `button {}` rule) - but that
|
||||||
|
rule is tag-scoped, so it never applied to "Voir les épisodes", the one
|
||||||
|
.tileBtn that's an <a>, not a <button> (a plain hash link, so the
|
||||||
|
browser's native navigation handles it with no JS needed). Left
|
||||||
|
unstyled beyond padding/text-align, it would render as a bare, small,
|
||||||
|
default-blue link sitting on the overlay - much less visible than
|
||||||
|
"Lancer une watch party" (a real <button>) or "Voir sur Emby" (an <a>,
|
||||||
|
but with the explicit .tileBtnLink look below) right next to it.
|
||||||
|
Styling .tileBtn itself, tag-agnostic, fixes that - and is the
|
||||||
|
semantically right call besides: for a Series tile, "Voir les épisodes"
|
||||||
|
IS the primary action (a Series is never itself directly playable, so
|
||||||
|
the two never appear on the same tile), not a secondary one. */
|
||||||
|
.tileBtn {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileBtn:hover { background: var(--accent-bright); }
|
||||||
|
|
||||||
|
/* "Voir sur Emby" only: the one secondary/neutral action among the tile
|
||||||
|
buttons - overrides just the two properties that make it read as
|
||||||
|
secondary, on top of .tileBtn's shared sizing/weight/radius above. */
|
||||||
|
.tileBtnLink {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileBtnLink:hover { background: #333; }
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.tileOverlay {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
inset: auto 0 26px 0;
|
||||||
|
flex-direction: row;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.tileBtn { font-size: 11px; padding: 6px 4px; }
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
// Shared poster-tile rendering - used by the home carousels and the library
|
// Shared poster-tile rendering - used by the home carousels, the library
|
||||||
// grid, so the two stay visually/behaviorally identical instead of drifting.
|
// grid, and the artist page, so they all stay visually/behaviorally
|
||||||
|
// identical instead of drifting.
|
||||||
|
import './tiles.css';
|
||||||
|
import type { ItemDto } from './types';
|
||||||
|
|
||||||
export function escapeHtml(str) {
|
export function escapeHtml(str: string): string {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
|
|
@ -10,12 +13,12 @@ export function escapeHtml(str) {
|
||||||
/** "m:ss" (or "h:mm:ss" past an hour) - shared by the album track list and
|
/** "m:ss" (or "h:mm:ss" past an hour) - shared by the album track list and
|
||||||
* the music player, so a track's listed duration and its playing position
|
* the music player, so a track's listed duration and its playing position
|
||||||
* always read the same way. */
|
* always read the same way. */
|
||||||
export function formatDuration(totalSeconds) {
|
export function formatDuration(totalSeconds: number): string {
|
||||||
const s = Math.max(0, Math.round(totalSeconds || 0));
|
const s = Math.max(0, Math.round(totalSeconds || 0));
|
||||||
const h = Math.floor(s / 3600);
|
const h = Math.floor(s / 3600);
|
||||||
const m = Math.floor((s % 3600) / 60);
|
const m = Math.floor((s % 3600) / 60);
|
||||||
const sec = s % 60;
|
const sec = s % 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(sec)}` : `${m}:${pad(sec)}`;
|
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -25,28 +28,28 @@ export function formatDuration(totalSeconds) {
|
||||||
// listener list, so the same person reads as the same color in either.
|
// listener list, so the same person reads as the same color in either.
|
||||||
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
|
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
|
||||||
|
|
||||||
export function colorForName(name) {
|
export function colorForName(name: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
||||||
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
|
return AVATAR_COLORS[hash % AVATAR_COLORS.length] as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initialsFor(name) {
|
export function initialsFor(name: string): string {
|
||||||
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
|
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drill-down targets: a tile for a type with no direct playback (Series,
|
// Drill-down targets: a tile for a type with no direct playback (Series,
|
||||||
// MusicArtist, MusicAlbum) links into its own browsing page instead of
|
// MusicArtist, MusicAlbum) links into its own browsing page instead of
|
||||||
// getting a "start a party" button - canWatchParty is already false for
|
// getting a "start a party" button - canWatchParty is already false for
|
||||||
// all three server-side (see WATCH_PARTY_TYPES in server/src/index.js).
|
// all three server-side (see WATCH_PARTY_TYPES in CatalogService).
|
||||||
const DRILL_DOWN = {
|
const DRILL_DOWN: Record<string, { route: string; label: string }> = {
|
||||||
Series: { route: 'series', label: 'Voir les épisodes' },
|
Series: { route: 'series', label: 'Voir les épisodes' },
|
||||||
MusicArtist: { route: 'artist', label: 'Voir les albums' },
|
MusicArtist: { route: 'artist', label: 'Voir les albums' },
|
||||||
MusicAlbum: { route: 'album', label: 'Voir les morceaux' }
|
MusicAlbum: { route: 'album', label: 'Voir les morceaux' }
|
||||||
};
|
};
|
||||||
|
|
||||||
export function tileHtml(item) {
|
export function tileHtml(item: ItemDto): string {
|
||||||
const drill = DRILL_DOWN[item.type];
|
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
|
||||||
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
|
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
|
||||||
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
|
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
|
||||||
return `
|
return `
|
||||||
|
|
@ -55,7 +58,7 @@ export function tileHtml(item) {
|
||||||
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
<div class="tileOverlay">
|
<div class="tileOverlay">
|
||||||
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">${partyLabel}</button>` : ''}
|
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">${partyLabel}</button>` : ''}
|
||||||
${drillHref ? `<a class="tileBtn" href="${drillHref}">${drill.label}</a>` : ''}
|
${drillHref ? `<a class="tileBtn" href="${drillHref}">${drill!.label}</a>` : ''}
|
||||||
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
</div>
|
</div>
|
||||||
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
||||||
|
|
@ -64,20 +67,20 @@ export function tileHtml(item) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wires up every "Lancer une watch party" button inside `container`.
|
* Wires up every "Lancer une watch party" button inside `container`.
|
||||||
* `onStart(itemId)` does the actual work (create the party + navigate) - kept
|
* `onStart(itemId)` does the actual work (create the party + navigate) -
|
||||||
* as a caller-supplied callback so this module doesn't need to depend on
|
* kept as a caller-supplied callback so this module doesn't need to
|
||||||
* party.js/router.js itself.
|
* depend on api/party.ts/router.ts itself.
|
||||||
*/
|
*/
|
||||||
export function wireTileButtons(container, onStart) {
|
export function wireTileButtons(container: ParentNode, onStart: (itemId: string) => Promise<void>): void {
|
||||||
container.querySelectorAll('.startPartyBtn').forEach((btn) => {
|
container.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn) => {
|
||||||
btn.addEventListener('click', async (e) => {
|
btn.addEventListener('click', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Démarrage...';
|
btn.textContent = 'Démarrage...';
|
||||||
try {
|
try {
|
||||||
await onStart(btn.dataset.itemId);
|
await onStart(btn.dataset.itemId as string);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert((err as Error).message);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Lancer une watch party';
|
btn.textContent = 'Lancer une watch party';
|
||||||
}
|
}
|
||||||
140
client/src/types.ts
Normal file
140
client/src/types.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
21
client/src/views/album/Album.css
Normal file
21
client/src/views/album/Album.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
10
client/src/views/album/Album.html
Normal file
10
client/src/views/album/Album.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<button class="linklike" id="back">← Retour</button>
|
||||||
|
<div class="albumHeader">
|
||||||
|
<img class="albumCover" id="albumCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="seriesInfo">
|
||||||
|
<h1 id="albumTitle"></h1>
|
||||||
|
<p class="hint hide" id="albumArtist"></p>
|
||||||
|
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="trackList" class="trackList"></div>
|
||||||
72
client/src/views/album/Album.ts
Normal file
72
client/src/views/album/Album.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
const albumId = params.id as string;
|
||||||
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
|
let album: ItemDto;
|
||||||
|
let tracks: ItemDto[];
|
||||||
|
try {
|
||||||
|
[album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]);
|
||||||
|
} catch {
|
||||||
|
root.innerHTML = '<p class="error">Impossible de charger cet album.</p>';
|
||||||
|
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)} <span class="seriesYear">(${album.year})</span>`
|
||||||
|
: 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 = '<p class="hint">Aucun morceau dans cet album.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = tracks.map((t) => `
|
||||||
|
<div class="trackRow">
|
||||||
|
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
|
||||||
|
<span class="trackName">${escapeHtml(t.name)}</span>
|
||||||
|
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
|
||||||
|
<button class="startPartyBtn" data-item-id="${t.id}">Écouter ensemble</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
listEl.querySelectorAll<HTMLButtonElement>('.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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
3
client/src/views/artist/Artist.html
Normal file
3
client/src/views/artist/Artist.html
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<button class="linklike" id="back">← Retour</button>
|
||||||
|
<div class="pageHeader"><h1></h1></div>
|
||||||
|
<div id="grid" class="grid"></div>
|
||||||
34
client/src/views/artist/Artist.ts
Normal file
34
client/src/views/artist/Artist.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
const artistId = params.id as string;
|
||||||
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
|
let artist;
|
||||||
|
let albums;
|
||||||
|
try {
|
||||||
|
[artist, albums] = await Promise.all([getItem(artistId), getArtistAlbums(artistId)]);
|
||||||
|
} catch {
|
||||||
|
root.innerHTML = '<p class="error">Impossible de charger cet artiste.</p>';
|
||||||
|
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('')
|
||||||
|
: '<p class="hint">Aucun album trouvé pour cet artiste.</p>';
|
||||||
|
}
|
||||||
44
client/src/views/home/Home.css
Normal file
44
client/src/views/home/Home.css
Normal file
|
|
@ -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; }
|
||||||
10
client/src/views/home/Home.html
Normal file
10
client/src/views/home/Home.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<div class="pageHeader">
|
||||||
|
<h1>Accueil</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="section" id="partiesSection" hidden>
|
||||||
|
<h2>Watch parties en cours</h2>
|
||||||
|
<div id="partiesCarousel" class="carousel"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="recentSections"></div>
|
||||||
|
|
@ -1,42 +1,36 @@
|
||||||
import { getLibraries, getRecentItems } from '../embyApi.js';
|
import './Home.css';
|
||||||
import { connectLobby, joinParty, createParty } from '../party.js';
|
import html from './Home.html';
|
||||||
import { navigate } from '../router.js';
|
import { getLibraries, getRecentItems } from '../../api/embyApi';
|
||||||
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
|
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) {
|
export function renderHome(root: HTMLElement): () => void {
|
||||||
root.innerHTML = `
|
root.innerHTML = html;
|
||||||
<div class="pageHeader">
|
|
||||||
<h1>Accueil</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section class="section" id="partiesSection" hidden>
|
|
||||||
<h2>Watch parties en cours</h2>
|
|
||||||
<div id="partiesCarousel" class="carousel"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div id="recentSections"></div>`;
|
|
||||||
|
|
||||||
// Live, not a one-shot fetch: the list (and each card's host/member count)
|
// Live, not a one-shot fetch: the list (and each card's host/member count)
|
||||||
// updates the moment anything changes anywhere - see
|
// updates the moment anything changes anywhere - see
|
||||||
// server/src/socketHandler.js's "lobby" room. The elapsed-time label is
|
// server/src/realtime/SocketGateway.ts's "lobby" room. The elapsed-time
|
||||||
// the one piece that ticks purely client-side (from createdUtc) - no
|
// label is the one piece that ticks purely client-side (from
|
||||||
// point re-pushing from the server once a second just for a clock.
|
// createdUtc) - no point re-pushing from the server once a second just
|
||||||
|
// for a clock.
|
||||||
const lobby = connectLobby((parties) => renderParties(root, parties));
|
const lobby = connectLobby((parties) => renderParties(root, parties));
|
||||||
const elapsedTimer = setInterval(() => updateElapsedLabels(root), 1000);
|
const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
|
||||||
|
|
||||||
loadRecentSections(root);
|
loadRecentSections(root);
|
||||||
|
|
||||||
// Called by router.js before rendering whatever comes next - stops the
|
// Called by router.ts before rendering whatever comes next - stops the
|
||||||
// lobby socket and the ticking clock from running after you've navigated
|
// lobby socket and the ticking clock from running after you've
|
||||||
// away (see docs/architecture.md's memory-leak writeup).
|
// navigated away (see docs/architecture.md's memory-leak writeup).
|
||||||
return function cleanup() {
|
return function cleanup() {
|
||||||
lobby.disconnect();
|
lobby.disconnect();
|
||||||
clearInterval(elapsedTimer);
|
clearInterval(elapsedTimer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderParties(root, parties) {
|
function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
|
||||||
const section = root.querySelector('#partiesSection');
|
const section = root.querySelector('#partiesSection') as HTMLElement | null;
|
||||||
const list = root.querySelector('#partiesCarousel');
|
const list = root.querySelector('#partiesCarousel');
|
||||||
if (!section || !list) return; // navigated away mid-flight
|
if (!section || !list) return; // navigated away mid-flight
|
||||||
|
|
||||||
|
|
@ -61,17 +55,17 @@ function renderParties(root, parties) {
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
list.querySelectorAll('.joinPartyBtn').forEach((btn) => {
|
list.querySelectorAll<HTMLButtonElement>('.joinPartyBtn').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
try {
|
try {
|
||||||
// Actually register as a member before navigating - the player's
|
// Actually register as a member before navigating - the player's
|
||||||
// realtime "join" is rejected server-side otherwise (you'd load the
|
// realtime "join" is rejected server-side otherwise (you'd load
|
||||||
// video but never get added to the members list or sync).
|
// the video but never get added to the members list or sync).
|
||||||
await joinParty(btn.dataset.partyId);
|
await joinParty(btn.dataset.partyId as string);
|
||||||
navigate(`/party/${btn.dataset.partyId}`);
|
navigate(`/party/${btn.dataset.partyId}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert((err as Error).message);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -80,30 +74,30 @@ function renderParties(root, parties) {
|
||||||
updateElapsedLabels(root);
|
updateElapsedLabels(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateElapsedLabels(root) {
|
function updateElapsedLabels(root: HTMLElement): void {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
root.querySelectorAll('.elapsed').forEach((el) => {
|
root.querySelectorAll<HTMLElement>('.elapsed').forEach((el) => {
|
||||||
const created = Number(el.dataset.created);
|
const created = Number(el.dataset.created);
|
||||||
if (!created) return;
|
if (!created) return;
|
||||||
el.textContent = formatElapsed(now - created);
|
el.textContent = formatElapsed(now - created);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatElapsed(ms) {
|
function formatElapsed(ms: number): string {
|
||||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||||
const h = Math.floor(totalSeconds / 3600);
|
const h = Math.floor(totalSeconds / 3600);
|
||||||
const m = Math.floor((totalSeconds % 3600) / 60);
|
const m = Math.floor((totalSeconds % 3600) / 60);
|
||||||
const s = totalSeconds % 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)}`;
|
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecentSections(root) {
|
async function loadRecentSections(root: HTMLElement): Promise<void> {
|
||||||
const container = root.querySelector('#recentSections');
|
const container = root.querySelector('#recentSections') as HTMLElement;
|
||||||
let libraries;
|
let libraries;
|
||||||
try {
|
try {
|
||||||
libraries = await getLibraries();
|
libraries = await getLibraries();
|
||||||
} catch (err) {
|
} catch {
|
||||||
container.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
|
container.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -120,7 +114,7 @@ async function loadRecentSections(root) {
|
||||||
section.remove();
|
section.remove();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const carousel = section.querySelector('.carousel');
|
const carousel = section.querySelector('.carousel')!;
|
||||||
carousel.innerHTML = items.map(tileHtml).join('');
|
carousel.innerHTML = items.map(tileHtml).join('');
|
||||||
wireTileButtons(carousel, async (itemId) => {
|
wireTileButtons(carousel, async (itemId) => {
|
||||||
const party = await createParty(itemId);
|
const party = await createParty(itemId);
|
||||||
2
client/src/views/library/Library.html
Normal file
2
client/src/views/library/Library.html
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<div class="pageHeader"><h1></h1></div>
|
||||||
|
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { getLibraries, getLibraryItems, getArtists } from '../embyApi.js';
|
import html from './Library.html';
|
||||||
import { createParty } from '../party.js';
|
import { getArtists, getLibraries, getLibraryItems } from '../../api/embyApi';
|
||||||
import { navigate } from '../router.js';
|
import { createParty } from '../../api/party';
|
||||||
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
|
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
|
* 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,
|
* - movies/tvshows/musicvideos (or anything else playable): a tile grid,
|
||||||
* same as the home page's carousels (Series/MusicArtist/MusicAlbum
|
* same as the home page's carousels (Series/MusicArtist/MusicAlbum
|
||||||
* tiles drill further in instead of starting a party directly - see
|
* 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
|
* - music: a tile grid too, just of artists (Emby's dedicated /Artists
|
||||||
* endpoint, not a generic library listing - see
|
* endpoint, not a generic library listing - see
|
||||||
* GET /api/catalog/libraries/:id/artists) - Album -> Tracks is one
|
* 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<void> {
|
||||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
let libraries;
|
let libraries;
|
||||||
try {
|
try {
|
||||||
libraries = await getLibraries();
|
libraries = await getLibraries();
|
||||||
} catch (err) {
|
} catch {
|
||||||
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -32,17 +34,16 @@ export async function renderLibrary(root, params) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = html;
|
||||||
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
|
root.querySelector('h1')!.textContent = library.name;
|
||||||
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>`;
|
|
||||||
|
|
||||||
const grid = root.querySelector('#grid');
|
const grid = root.querySelector('#grid') as HTMLElement;
|
||||||
let items;
|
let items;
|
||||||
try {
|
try {
|
||||||
items = library.collectionType === 'music'
|
items = library.collectionType === 'music'
|
||||||
? await getArtists(library.id)
|
? await getArtists(library.id)
|
||||||
: await getLibraryItems(library.id);
|
: await getLibraryItems(library.id);
|
||||||
} catch (err) {
|
} catch {
|
||||||
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
14
client/src/views/login/Login.html
Normal file
14
client/src/views/login/Login.html
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<main class="wrap">
|
||||||
|
<h1>Watch Party</h1>
|
||||||
|
<form id="loginForm" class="panel">
|
||||||
|
<p class="hint">Utilise ton compte Emby habituel.</p>
|
||||||
|
<label>Nom d'utilisateur
|
||||||
|
<input id="username" autocomplete="username" required autofocus />
|
||||||
|
</label>
|
||||||
|
<label>Mot de passe
|
||||||
|
<input id="password" type="password" autocomplete="current-password" required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Se connecter</button>
|
||||||
|
<p id="error" class="error"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
22
client/src/views/login/Login.ts
Normal file
22
client/src/views/login/Login.ts
Normal file
|
|
@ -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.";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
61
client/src/views/musicPlayer/MusicPlayer.css
Normal file
61
client/src/views/musicPlayer/MusicPlayer.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
28
client/src/views/musicPlayer/MusicPlayer.html
Normal file
28
client/src/views/musicPlayer/MusicPlayer.html
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<div class="pageHeader">
|
||||||
|
<h1>Écoute partagée</h1>
|
||||||
|
<button class="linklike" id="leaveBtn">Quitter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
|
||||||
|
|
||||||
|
<div class="nowPlaying" id="nowPlaying">
|
||||||
|
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="nowPlayingInfo">
|
||||||
|
<strong id="trackName">-</strong>
|
||||||
|
<span class="hint" id="trackArtist"></span>
|
||||||
|
<div class="nowPlayingControls">
|
||||||
|
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">▶</button>
|
||||||
|
<span id="trackPosition" class="hint">0:00 / 0:00</span>
|
||||||
|
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<audio id="audio"></audio>
|
||||||
|
|
||||||
|
<div class="pageHeader">
|
||||||
|
<h2>File d'attente</h2>
|
||||||
|
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
|
||||||
|
</div>
|
||||||
|
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
|
||||||
|
|
||||||
|
<div id="browsePanel" class="browsePanel hide"></div>
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { getLibraries, getArtists, getArtistAlbums, getAlbumTracks, audioStreamUrl } from '../embyApi.js';
|
import '../../styles/presence.css';
|
||||||
import { leaveParty, connectParty, addTrackToQueue } from '../party.js';
|
import '../../styles/trackList.css';
|
||||||
import { navigate } from '../router.js';
|
import './MusicPlayer.css';
|
||||||
import { escapeHtml, formatDuration, colorForName, initialsFor } from '../tiles.js';
|
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
|
* 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
|
* 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
|
* 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;
|
* 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
|
* skipping the current track needs a strict majority vote of currently
|
||||||
* connected members. No host-only controls at all - see
|
* 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
|
* server-authoritative auto-advance timer that drives this without any
|
||||||
* single client having to be "in charge".
|
* single client having to be "in charge".
|
||||||
*/
|
*/
|
||||||
export function renderMusicPlayer(root, partyId, party) {
|
export function renderMusicPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn {
|
||||||
root.innerHTML = `
|
root.innerHTML = html;
|
||||||
<div class="pageHeader">
|
|
||||||
<h1>Écoute partagée</h1>
|
|
||||||
<button class="linklike" id="leaveBtn">Quitter</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="presenceOverlay" class="presenceOverlay musicPresence"></div>
|
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')!;
|
||||||
|
|
||||||
<div class="nowPlaying" id="nowPlaying">
|
let conn: PartyConnection | null = null;
|
||||||
<img id="trackImage" class="nowPlayingImage" alt="" onerror="this.classList.add('noImage')" />
|
let hls: HlsInstance | null = null;
|
||||||
<div class="nowPlayingInfo">
|
let currentItemId: string | null = null;
|
||||||
<strong id="trackName">-</strong>
|
|
||||||
<span class="hint" id="trackArtist"></span>
|
|
||||||
<div class="nowPlayingControls">
|
|
||||||
<button class="iconBtn" id="playPauseBtn" title="Lecture / pause" aria-label="Lecture / pause">▶</button>
|
|
||||||
<span id="trackPosition" class="hint">0:00 / 0:00</span>
|
|
||||||
<button class="linklike" id="voteSkipBtn">Passer (vote)</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<audio id="audio"></audio>
|
|
||||||
|
|
||||||
<div class="pageHeader">
|
|
||||||
<h2>File d'attente</h2>
|
|
||||||
<button class="linklike" id="toggleBrowseBtn">Ajouter des morceaux</button>
|
|
||||||
</div>
|
|
||||||
<div id="queueList" class="trackList"><p class="hint">Rien en attente.</p></div>
|
|
||||||
|
|
||||||
<div id="browsePanel" class="browsePanel hide"></div>`;
|
|
||||||
|
|
||||||
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 currentDurationSeconds = 0;
|
let currentDurationSeconds = 0;
|
||||||
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as player.js
|
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event - same pattern as Player.ts
|
||||||
let positionTicker = null;
|
let positionTicker: number | null = null;
|
||||||
let latestMembers = [];
|
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
|
// PlaybackInfo negotiation isn't video-specific, so its TranscodingUrl
|
||||||
// for a music track just as plausibly comes back HLS-shaped, and most
|
// for a music track just as plausibly comes back HLS-shaped, and most
|
||||||
// browsers (everything but Safari) have no native .m3u8 support at all -
|
// 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
|
// is actually parsed, not immediately after setting src - setting
|
||||||
// currentTime before a fresh source has loaded metadata is unreliable
|
// currentTime before a fresh source has loaded metadata is unreliable
|
||||||
// and gets silently reset by most browsers.
|
// and gets silently reset by most browsers.
|
||||||
function loadTrack(track, elapsedSeconds, isPaused) {
|
function loadTrack(track: MusicCurrentPublic, elapsedSeconds: number, isPaused: boolean) {
|
||||||
currentItemId = track.itemId;
|
currentItemId = track.itemId;
|
||||||
currentDurationSeconds = track.durationSeconds || 0;
|
currentDurationSeconds = track.durationSeconds || 0;
|
||||||
trackImage.src = track.itemImageUrl || '';
|
trackImage.src = track.itemImageUrl || '';
|
||||||
|
|
@ -101,16 +80,16 @@ export function renderMusicPlayer(root, partyId, party) {
|
||||||
// Caught, not left to propagate: this runs from inside applyMusicState,
|
// Caught, not left to propagate: this runs from inside applyMusicState,
|
||||||
// which every "queueUpdated" push calls - an uncaught throw here would
|
// which every "queueUpdated" push calls - an uncaught throw here would
|
||||||
// abort that entire handler (and, the first time it happens, the
|
// 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
|
// 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
|
// 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.
|
// just needs to not crash on the way there.
|
||||||
try {
|
try {
|
||||||
if (window.Hls && window.Hls.isSupported()) {
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
hls = new window.Hls();
|
hls = new window.Hls();
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
|
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);
|
if (data.fatal) console.error('[watchparty musicPlayer] HLS error', data);
|
||||||
});
|
});
|
||||||
hls.loadSource(src);
|
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 -
|
// Queue/vote-button/play-icon updates happen first, unconditionally -
|
||||||
// deliberately BEFORE the track-loading branch below, which touches
|
// deliberately BEFORE the track-loading branch below, which touches
|
||||||
// hls.js and could throw (a bad manifest, a network hiccup). Same
|
// hls.js and could throw (a bad manifest, a network hiccup). Same
|
||||||
// ordering the video player already uses (see onItemChanged in
|
// 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
|
// loadVideo): a media-loading failure should never also take the
|
||||||
// queue list and vote tally down with it, since they're not actually
|
// queue list and vote tally down with it, since they're not actually
|
||||||
// related to whether THIS particular track's audio loaded.
|
// related to whether THIS particular track's audio loaded.
|
||||||
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
|
playPauseBtn.textContent = music.current && !music.current.isPaused ? '⏸' : '▶';
|
||||||
playPauseBtn.disabled = !music.current;
|
playPauseBtn.disabled = !music.current;
|
||||||
voteSkipBtn.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
|
// (strict majority of currently connected members) - computed here from
|
||||||
// the member list the "members" event already provides, rather than
|
// the member list the "members" event already provides, rather than
|
||||||
// MusicQueueService's own broadcast repeating that count on every
|
// MusicQueueService's own broadcast repeating that count on every
|
||||||
|
|
@ -191,18 +170,18 @@ export function renderMusicPlayer(root, partyId, party) {
|
||||||
conn.setMusicPaused(!audio.paused);
|
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
|
// Ticks the visible position label between server updates - the same
|
||||||
// "derive it client-side from a snapshot" approach as the home page's
|
// "derive it client-side from a snapshot" approach as the home page's
|
||||||
// party-elapsed-time labels, rather than the server pushing this every
|
// party-elapsed-time labels, rather than the server pushing this every
|
||||||
// second for every listener.
|
// second for every listener.
|
||||||
positionTicker = setInterval(() => {
|
positionTicker = window.setInterval(() => {
|
||||||
if (!currentItemId) return;
|
if (!currentItemId) return;
|
||||||
trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
|
trackPosition.textContent = `${formatDuration(audio.currentTime)} / ${formatDuration(currentDurationSeconds)}`;
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
function renderMembers(members) {
|
function renderMembers(members: SocketMember[]) {
|
||||||
latestMembers = members;
|
latestMembers = members;
|
||||||
presenceOverlay.innerHTML = members.map((m) => `
|
presenceOverlay.innerHTML = members.map((m) => `
|
||||||
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
|
<div class="avatar" style="background:${colorForName(m.userName)}" title="${escapeHtml(m.userName)}">
|
||||||
|
|
@ -214,7 +193,7 @@ export function renderMusicPlayer(root, partyId, party) {
|
||||||
// within this page (see class comment for why not a route navigation). ---
|
// within this page (see class comment for why not a route navigation). ---
|
||||||
let browsing = false;
|
let browsing = false;
|
||||||
|
|
||||||
root.querySelector('#toggleBrowseBtn').addEventListener('click', () => {
|
root.querySelector('#toggleBrowseBtn')!.addEventListener('click', () => {
|
||||||
browsing = !browsing;
|
browsing = !browsing;
|
||||||
browsePanel.classList.toggle('hide', !browsing);
|
browsePanel.classList.toggle('hide', !browsing);
|
||||||
if (browsing) loadMusicLibraries();
|
if (browsing) loadMusicLibraries();
|
||||||
|
|
@ -231,92 +210,95 @@ export function renderMusicPlayer(root, partyId, party) {
|
||||||
// Almost always exactly one - skip straight to its artists rather
|
// Almost always exactly one - skip straight to its artists rather
|
||||||
// than making the common case click through a one-item list.
|
// than making the common case click through a one-item list.
|
||||||
if (libraries.length === 1) {
|
if (libraries.length === 1) {
|
||||||
loadArtists(libraries[0].id);
|
loadArtists(libraries[0]!.id);
|
||||||
} else {
|
} 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));
|
wireBrowseList((id) => loadArtists(id));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch {
|
||||||
browsePanel.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadArtists(libraryId) {
|
async function loadArtists(libraryId: string) {
|
||||||
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
try {
|
try {
|
||||||
const artists = await getArtists(libraryId);
|
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));
|
wireBrowseList((id) => loadAlbums(id));
|
||||||
} catch (err) {
|
} catch {
|
||||||
browsePanel.innerHTML = '<p class="error">Impossible de charger les artistes.</p>';
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les artistes.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadAlbums(artistId) {
|
async function loadAlbums(artistId: string) {
|
||||||
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
try {
|
try {
|
||||||
const albums = await getArtistAlbums(artistId);
|
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));
|
wireBrowseList((id) => loadTracks(id));
|
||||||
} catch (err) {
|
} catch {
|
||||||
browsePanel.innerHTML = '<p class="error">Impossible de charger les albums.</p>';
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les albums.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTracks(albumId) {
|
async function loadTracks(albumId: string) {
|
||||||
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
browsePanel.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
try {
|
try {
|
||||||
const tracks = await getAlbumTracks(albumId);
|
const tracks = await getAlbumTracks(albumId);
|
||||||
browsePanel.innerHTML = `
|
browsePanel.innerHTML = `
|
||||||
<button class="linklike browseBack">← Retour</button>
|
<button class="linklike browseBack">← Retour</button>
|
||||||
${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 `
|
||||||
<div class="trackRow">
|
<div class="trackRow">
|
||||||
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
|
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
|
||||||
<span class="trackName">${escapeHtml(t.name)}</span>
|
<span class="trackName">${escapeHtml(t.name)}</span>
|
||||||
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}</span>
|
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / 10000000) : ''}</span>
|
||||||
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify({
|
<button class="addTrackBtn" data-track="${encodeURIComponent(JSON.stringify(input))}">Ajouter</button>
|
||||||
itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName,
|
</div>`;
|
||||||
itemImageUrl: t.imageUrl, durationTicks: t.durationTicks
|
}).join('')}`;
|
||||||
}))}">Ajouter</button>
|
browsePanel.querySelector('.browseBack')!.addEventListener('click', () => loadMusicLibraries());
|
||||||
</div>`).join('')}`;
|
browsePanel.querySelectorAll<HTMLButtonElement>('.addTrackBtn').forEach((btn) => {
|
||||||
browsePanel.querySelector('.browseBack').addEventListener('click', () => loadMusicLibraries());
|
|
||||||
browsePanel.querySelectorAll('.addTrackBtn').forEach((btn) => {
|
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const originalLabel = btn.textContent;
|
const originalLabel = btn.textContent as string;
|
||||||
try {
|
try {
|
||||||
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track)));
|
await addTrackToQueue(partyId, JSON.parse(decodeURIComponent(btn.dataset.track as string)));
|
||||||
btn.textContent = 'Ajouté !';
|
btn.textContent = 'Ajouté !';
|
||||||
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
|
setTimeout(() => { btn.textContent = originalLabel; btn.disabled = false; }, 1500);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message);
|
alert((err as Error).message);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch {
|
||||||
browsePanel.innerHTML = '<p class="error">Impossible de charger les morceaux.</p>';
|
browsePanel.innerHTML = '<p class="error">Impossible de charger les morceaux.</p>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBrowseList(items, onBack) {
|
function renderBrowseList(items: { id: string; name: string }[], withBack: boolean): string {
|
||||||
const back = onBack ? '<button class="linklike browseBack">← Retour</button>' : '';
|
const back = withBack ? '<button class="linklike browseBack">← Retour</button>' : '';
|
||||||
const body = items.length
|
const body = items.length
|
||||||
? items.map((i) => `<button class="linklike browseItem" data-id="${escapeHtml(i.id)}">${escapeHtml(i.name)}</button>`).join('')
|
? items.map((i) => `<button class="linklike browseItem" data-id="${escapeHtml(i.id)}">${escapeHtml(i.name)}</button>`).join('')
|
||||||
: '<p class="hint">Rien à afficher.</p>';
|
: '<p class="hint">Rien à afficher.</p>';
|
||||||
return `${back}<div class="browseList">${body}</div>`;
|
return `${back}<div class="browseList">${body}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireBrowseList(onPick) {
|
function wireBrowseList(onPick: (id: string) => void) {
|
||||||
const back = browsePanel.querySelector('.browseBack');
|
const back = browsePanel.querySelector('.browseBack');
|
||||||
if (back) back.addEventListener('click', () => loadMusicLibraries());
|
if (back) back.addEventListener('click', () => loadMusicLibraries());
|
||||||
browsePanel.querySelectorAll('.browseItem').forEach((btn) => {
|
browsePanel.querySelectorAll<HTMLButtonElement>('.browseItem').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => onPick(btn.dataset.id));
|
btn.addEventListener('click', () => onPick(btn.dataset.id as string));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
applyMusicState(party.music);
|
if (party.music) applyMusicState(party.music);
|
||||||
|
|
||||||
conn = connectParty(partyId, {
|
conn = connectParty(partyId, {
|
||||||
onMembers: renderMembers,
|
onMembers: renderMembers,
|
||||||
|
|
@ -325,14 +307,14 @@ export function renderMusicPlayer(root, partyId, party) {
|
||||||
onClose: () => {}
|
onClose: () => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
|
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
|
||||||
|
|
||||||
return function cleanup() {
|
return function cleanup() {
|
||||||
if (positionTicker) clearInterval(positionTicker);
|
if (positionTicker) clearInterval(positionTicker);
|
||||||
if (hls) hls.destroy();
|
if (hls) hls.destroy();
|
||||||
audio.pause();
|
audio.pause();
|
||||||
audio.removeAttribute('src');
|
audio.removeAttribute('src');
|
||||||
if (conn) conn.leave();
|
conn?.leave();
|
||||||
leaveParty(partyId).catch(() => {});
|
leaveParty(partyId).catch(() => {});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
195
client/src/views/player/Player.css
Normal file
195
client/src/views/player/Player.css
Normal file
|
|
@ -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 <p>'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;
|
||||||
|
}
|
||||||
34
client/src/views/player/Player.html
Normal file
34
client/src/views/player/Player.html
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<div class="playerWrap">
|
||||||
|
<video id="video" playsinline></video>
|
||||||
|
|
||||||
|
<p id="loadingNotice" class="loadingNotice">Chargement du flux...</p>
|
||||||
|
<div id="playerError" class="playerError hide">
|
||||||
|
<p id="playerErrorText"></p>
|
||||||
|
<button id="playerErrorRetry" class="linklike">Réessayer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
|
||||||
|
|
||||||
|
<div class="topOverlay">
|
||||||
|
<div class="actionBar">
|
||||||
|
<span id="itemTitle" class="itemTitle"></span>
|
||||||
|
<span id="members" class="members"></span>
|
||||||
|
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
||||||
|
|
||||||
|
<div id="settingsWrap" class="settingsWrap hide">
|
||||||
|
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</button>
|
||||||
|
<div id="settingsPanel" class="settingsPanel hide">
|
||||||
|
<label>Langue <select id="audioSelect"></select></label>
|
||||||
|
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
||||||
|
<label>Qualité <select id="qualitySelect"></select></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="leaveBtn" class="linklike">Quitter</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="presenceOverlay" class="presenceOverlay"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
|
||||||
|
</div>
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { streamUrl, getPlaybackOptions, auth } from '../embyApi.js';
|
import '../../styles/presence.css';
|
||||||
import { getParty, leaveParty, connectParty } from '../party.js';
|
import './Player.css';
|
||||||
import { navigate } from '../router.js';
|
import html from './Player.html';
|
||||||
import { escapeHtml, colorForName, initialsFor } from '../tiles.js';
|
import { auth, getPlaybackOptions, streamUrl } from '../../api/embyApi';
|
||||||
import { renderMusicPlayer } from './musicPlayer.js';
|
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 TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns
|
||||||
const DRIFT_TOLERANCE_SECONDS = 1.5;
|
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
|
const PAUSE_REQUEST_HIGHLIGHT_MS = 4000; // how long the banner + the orange badge stay up
|
||||||
|
|
||||||
// "Quality" is really "max transcoding bitrate", the same simplification
|
// "Quality" is really "max transcoding bitrate", the same simplification
|
||||||
|
|
@ -35,7 +41,7 @@ const PAUSE_ICON_SVG = `
|
||||||
* before they can render anything at all.
|
* before they can render anything at all.
|
||||||
*
|
*
|
||||||
* Has to return its cleanup function *synchronously* (route()'s contract -
|
* 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
|
* 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
|
* 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 -
|
* 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
|
* when it runs (nothing was ever set up in that case - no socket, no
|
||||||
* timer, nothing to leak).
|
* timer, nothing to leak).
|
||||||
*/
|
*/
|
||||||
export function renderPartyPlayer(root, params) {
|
export function renderPartyPlayer(root: HTMLElement, params: RouteParams): CleanupFn {
|
||||||
const partyId = params.id;
|
const partyId = params.id as string;
|
||||||
root.innerHTML = '<p class="hint">Chargement...</p>';
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
let cleanup = () => {};
|
let cleanup: CleanupFn = () => {};
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
getParty(partyId).then((party) => {
|
getParty(partyId).then((party) => {
|
||||||
|
|
@ -75,64 +81,30 @@ export function renderPartyPlayer(root, params) {
|
||||||
* Takes the already-fetched party instead of fetching it itself: the
|
* Takes the already-fetched party instead of fetching it itself: the
|
||||||
* dispatcher needs to fetch it anyway just to read mediaType, and
|
* dispatcher needs to fetch it anyway just to read mediaType, and
|
||||||
* route()'s contract needs its cleanup function back *synchronously* (see
|
* route()'s contract needs its cleanup function back *synchronously* (see
|
||||||
* router.js) - an async renderVideoPlayer awaiting its own fetch would
|
* router.ts) - an async renderVideoPlayer awaiting its own fetch would
|
||||||
* return a Promise instead, which router.js's `typeof cleanup ===
|
* return a Promise instead, which router.ts's `typeof cleanup ===
|
||||||
* 'function'` check would silently skip, reintroducing the exact leak
|
* 'function'` check would silently skip, reintroducing the exact leak
|
||||||
* class this app already spent real effort fixing once.
|
* class this app already spent real effort fixing once.
|
||||||
*/
|
*/
|
||||||
function renderVideoPlayer(root, partyId, party) {
|
function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPublicState): CleanupFn {
|
||||||
root.innerHTML = `
|
root.innerHTML = html;
|
||||||
<div class="playerWrap">
|
|
||||||
<video id="video" playsinline></video>
|
|
||||||
|
|
||||||
<p id="loadingNotice" class="loadingNotice">Chargement du flux...</p>
|
const video = root.querySelector('#video') as HTMLVideoElement;
|
||||||
<div id="playerError" class="playerError hide">
|
const loadingNotice = root.querySelector('#loadingNotice')!;
|
||||||
<p id="playerErrorText"></p>
|
const errorBox = root.querySelector('#playerError')!;
|
||||||
<button id="playerErrorRetry" class="linklike">Réessayer</button>
|
const errorText = root.querySelector('#playerErrorText')!;
|
||||||
</div>
|
const itemTitleEl = root.querySelector('#itemTitle')!;
|
||||||
|
const membersEl = root.querySelector('#members')!;
|
||||||
<div id="pauseRequestBanner" class="pauseRequestBanner hide"></div>
|
const presenceOverlay = root.querySelector('#presenceOverlay')!;
|
||||||
|
const pauseBanner = root.querySelector('#pauseRequestBanner')!;
|
||||||
<div class="topOverlay">
|
const nextBtn = root.querySelector('#nextBtn') as HTMLButtonElement;
|
||||||
<div class="actionBar">
|
const followerNotice = root.querySelector('#followerNotice')!;
|
||||||
<span id="itemTitle" class="itemTitle"></span>
|
const settingsWrap = root.querySelector('#settingsWrap')!;
|
||||||
<span id="members" class="members"></span>
|
const settingsBtn = root.querySelector('#settingsBtn')!;
|
||||||
<button id="nextBtn" class="linklike hide">Épisode suivant →</button>
|
const settingsPanel = root.querySelector('#settingsPanel')!;
|
||||||
|
const audioSelect = root.querySelector('#audioSelect') as HTMLSelectElement;
|
||||||
<div id="settingsWrap" class="settingsWrap hide">
|
const subtitleSelect = root.querySelector('#subtitleSelect') as HTMLSelectElement;
|
||||||
<button id="settingsBtn" class="iconBtn" title="Paramètres de lecture" aria-label="Paramètres de lecture">⚙</button>
|
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
||||||
<div id="settingsPanel" class="settingsPanel hide">
|
|
||||||
<label>Langue <select id="audioSelect"></select></label>
|
|
||||||
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
|
|
||||||
<label>Qualité <select id="qualitySelect"></select></label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button id="leaveBtn" class="linklike">Quitter</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="presenceOverlay" class="presenceOverlay"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => `<option value="${q.value}">${q.label}</option>`).join('');
|
||||||
|
|
||||||
|
|
@ -142,9 +114,9 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
// never be reachable to remove, and would pile up across visits. This was
|
// 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"
|
// the actual leak: every join-a-party-then-navigate-away-without-"Quitter"
|
||||||
// used to leave one of these (plus the heartbeat interval, the socket,
|
// 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.
|
// and the hls.js instance) running forever - see router.ts's cleanup hook.
|
||||||
function onDocumentClick(e) {
|
function onDocumentClick(e: MouseEvent) {
|
||||||
if (!settingsWrap.contains(e.target)) settingsPanel.classList.add('hide');
|
if (!settingsWrap.contains(e.target as Node)) settingsPanel.classList.add('hide');
|
||||||
}
|
}
|
||||||
document.addEventListener('click', onDocumentClick);
|
document.addEventListener('click', onDocumentClick);
|
||||||
|
|
||||||
|
|
@ -155,15 +127,15 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
|
|
||||||
let isHost = false;
|
let isHost = false;
|
||||||
let hasNextQueued = false;
|
let hasNextQueued = false;
|
||||||
let conn = null;
|
let conn: PartyConnection | null = null;
|
||||||
let hls = null;
|
let hls: HlsInstance | null = null;
|
||||||
let currentItemId = null;
|
let currentItemId: string | null = null;
|
||||||
let suppressLocalEvents = false; // avoid feeding our own correction back as a new event
|
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 pauseRequestCooldownUntil = 0;
|
||||||
let pauseBannerTimer = null;
|
let pauseBannerTimer: number | null = null;
|
||||||
|
|
||||||
function showError(message, detail) {
|
function showError(message: string, detail?: unknown) {
|
||||||
loadingNotice.classList.add('hide');
|
loadingNotice.classList.add('hide');
|
||||||
errorText.textContent = message;
|
errorText.textContent = message;
|
||||||
errorBox.classList.remove('hide');
|
errorBox.classList.remove('hide');
|
||||||
|
|
@ -174,7 +146,7 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
errorBox.classList.add('hide');
|
errorBox.classList.add('hide');
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyRemoteState(msg) {
|
function applyRemoteState(msg: { isPaused: boolean; positionTicks: number }) {
|
||||||
suppressLocalEvents = true;
|
suppressLocalEvents = true;
|
||||||
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
|
const targetSeconds = msg.positionTicks / TICKS_PER_SECOND;
|
||||||
if (Math.abs(video.currentTime - targetSeconds) > DRIFT_TOLERANCE_SECONDS) {
|
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
|
* 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
|
* were) or on a live "hostChanged" event (the previous host left and the
|
||||||
* server picked us next - see socketHandler.js's applyLeave). Safe to
|
* server picked us next - see SocketGateway's applyLeave). Safe to call
|
||||||
* call more than once: the early return covers the normal case, and
|
* more than once: the early return covers the normal case, and
|
||||||
* addEventListener with the same function reference is a no-op if already
|
* addEventListener with the same function reference is a no-op if
|
||||||
* attached, so even a re-entrant call can't double up the play/pause/
|
* already attached, so even a re-entrant call can't double up the
|
||||||
* seeked listeners. The heartbeat is guarded separately since setInterval
|
* play/pause/seeked listeners. The heartbeat is guarded separately since
|
||||||
* has no such built-in idempotence - calling it twice would leak the
|
* setInterval has no such built-in idempotence - calling it twice would
|
||||||
* first interval's id.
|
* leak the first interval's id.
|
||||||
*/
|
*/
|
||||||
function becomeHost(notify) {
|
function becomeHost(notify: boolean) {
|
||||||
if (isHost) return;
|
if (isHost) return;
|
||||||
isHost = true;
|
isHost = true;
|
||||||
video.controls = true;
|
video.controls = true;
|
||||||
|
|
@ -212,13 +184,13 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
video.addEventListener('play', sendLocalState);
|
video.addEventListener('play', sendLocalState);
|
||||||
video.addEventListener('pause', sendLocalState);
|
video.addEventListener('pause', sendLocalState);
|
||||||
video.addEventListener('seeked', sendLocalState);
|
video.addEventListener('seeked', sendLocalState);
|
||||||
video.addEventListener('ended', () => { if (hasNextQueued) conn.next(); });
|
video.addEventListener('ended', () => { if (hasNextQueued) conn?.next(); });
|
||||||
if (!heartbeat) heartbeat = setInterval(sendLocalState, 3000);
|
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.");
|
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 {
|
try {
|
||||||
const options = await getPlaybackOptions(itemId);
|
const options = await getPlaybackOptions(itemId);
|
||||||
audioSelect.innerHTML = options.audioTracks.length
|
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;
|
currentItemId = itemId;
|
||||||
clearError();
|
clearError();
|
||||||
loadingNotice.classList.remove('hide');
|
loadingNotice.classList.remove('hide');
|
||||||
|
|
@ -257,7 +229,7 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
if (window.Hls && window.Hls.isSupported()) {
|
if (window.Hls && window.Hls.isSupported()) {
|
||||||
hls = new window.Hls();
|
hls = new window.Hls();
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, resume);
|
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;
|
if (!data.fatal) return;
|
||||||
showError(
|
showError(
|
||||||
"Erreur de lecture (HLS) - le flux n'a pas pu être chargé depuis Emby.",
|
"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
|
// Changing language/subtitles/quality means a fresh transcode (Emby bakes
|
||||||
// the selection in server-side - see index.js) - reload at the same
|
// the selection in server-side - see CatalogService) - reload at the
|
||||||
// position instead of restarting from zero.
|
// same position instead of restarting from zero.
|
||||||
function reloadWithCurrentOptions() {
|
function reloadWithCurrentOptions() {
|
||||||
loadVideo(currentItemId, video.currentTime, !video.paused);
|
if (currentItemId) loadVideo(currentItemId, video.currentTime, !video.paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
[audioSelect, subtitleSelect, qualitySelect].forEach((sel) => {
|
[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);
|
if (currentItemId) loadVideo(currentItemId, video.currentTime, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function onItemChanged(msg) {
|
async function onItemChanged(msg: ItemChangedMsg) {
|
||||||
itemTitleEl.textContent = msg.itemName || '';
|
itemTitleEl.textContent = msg.itemName || '';
|
||||||
hasNextQueued = !!msg.hasNext;
|
hasNextQueued = !!msg.hasNext;
|
||||||
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
nextBtn.classList.toggle('hide', !(isHost && hasNextQueued));
|
||||||
|
|
@ -312,7 +284,7 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
// The pause-badge <span> is always in the markup, just hidden by
|
// The pause-badge <span> is always in the markup, just hidden by
|
||||||
// .avatarPauseRequested's CSS - see onPauseRequested(), which is the only
|
// .avatarPauseRequested's CSS - see onPauseRequested(), which is the only
|
||||||
// thing that ever shows it.
|
// thing that ever shows it.
|
||||||
function renderMembers(members) {
|
function renderMembers(members: SocketMember[]) {
|
||||||
membersEl.textContent = members
|
membersEl.textContent = members
|
||||||
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
|
.map((m) => m.userName + (m.isHost ? ' (hôte)' : ''))
|
||||||
.join(', ');
|
.join(', ');
|
||||||
|
|
@ -342,13 +314,13 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast to everyone in the party, including the requester and the
|
// 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.
|
// matter whose screen it renders on.
|
||||||
function onPauseRequested(msg) {
|
function onPauseRequested(msg: PauseRequestedMsg) {
|
||||||
pauseBanner.textContent = `${msg.userName} demande une pause`;
|
pauseBanner.textContent = `${msg.userName} demande une pause`;
|
||||||
pauseBanner.classList.remove('hide');
|
pauseBanner.classList.remove('hide');
|
||||||
if (pauseBannerTimer) clearTimeout(pauseBannerTimer);
|
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)}"]`);
|
const avatar = presenceOverlay.querySelector(`.avatar[data-user-id="${CSS.escape(msg.userId)}"]`);
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
|
|
@ -378,12 +350,12 @@ function renderVideoPlayer(root, partyId, party) {
|
||||||
if (party.youAreHost) becomeHost(false);
|
if (party.youAreHost) becomeHost(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextBtn.addEventListener('click', () => { if (conn) conn.next(); });
|
nextBtn.addEventListener('click', () => { conn?.next(); });
|
||||||
root.querySelector('#leaveBtn').addEventListener('click', () => navigate('/home'));
|
(root.querySelector('#leaveBtn') as HTMLButtonElement).addEventListener('click', () => navigate('/home'));
|
||||||
|
|
||||||
init();
|
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
|
// whether that's from clicking "Quitter" (navigate above) or leaving any
|
||||||
// other way (sidebar link, browser back, ...). This is the fix for the
|
// other way (sidebar link, browser back, ...). This is the fix for the
|
||||||
// actual leak: previously, only the "Quitter" button ran any of this.
|
// 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 (pauseBannerTimer) clearTimeout(pauseBannerTimer);
|
||||||
if (hls) hls.destroy();
|
if (hls) hls.destroy();
|
||||||
document.removeEventListener('click', onDocumentClick);
|
document.removeEventListener('click', onDocumentClick);
|
||||||
if (conn) conn.leave();
|
conn?.leave();
|
||||||
leaveParty(partyId).catch(() => {});
|
leaveParty(partyId).catch(() => {});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
91
client/src/views/series/Series.css
Normal file
91
client/src/views/series/Series.css
Normal file
|
|
@ -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%; }
|
||||||
|
}
|
||||||
11
client/src/views/series/Series.html
Normal file
11
client/src/views/series/Series.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
<button class="linklike" id="back">← Retour</button>
|
||||||
|
<div class="seriesHeader">
|
||||||
|
<img class="seriesPoster" id="seriesPoster" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="seriesInfo">
|
||||||
|
<h1 id="seriesTitle"></h1>
|
||||||
|
<p class="seriesOverview hide" id="seriesOverview"></p>
|
||||||
|
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav id="seasonTabs" class="tabs"></nav>
|
||||||
|
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>
|
||||||
103
client/src/views/series/Series.ts
Normal file
103
client/src/views/series/Series.ts
Normal file
|
|
@ -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<void> {
|
||||||
|
const seriesId = params.id as string;
|
||||||
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
|
let series: ItemDto;
|
||||||
|
let seasons: SeasonDto[];
|
||||||
|
try {
|
||||||
|
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
|
||||||
|
} catch {
|
||||||
|
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
|
||||||
|
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)} <span class="seriesYear">(${series.year})</span>`
|
||||||
|
: 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 = '<p class="hint">Aucune saison trouvée.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsEl = root.querySelector('#seasonTabs')!;
|
||||||
|
tabsEl.innerHTML = seasons
|
||||||
|
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
tabsEl.querySelectorAll<HTMLButtonElement>('.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<void> {
|
||||||
|
const listEl = root.querySelector('#episodeList')!;
|
||||||
|
listEl.innerHTML = '<p class="hint">Chargement...</p>';
|
||||||
|
|
||||||
|
let episodes: ItemDto[];
|
||||||
|
try {
|
||||||
|
episodes = await getSeriesEpisodes(seriesId, seasonId);
|
||||||
|
} catch {
|
||||||
|
listEl.innerHTML = '<p class="error">Impossible de charger les épisodes.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!episodes.length) {
|
||||||
|
listEl.innerHTML = '<p class="hint">Aucun épisode dans cette saison.</p>';
|
||||||
|
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) => `
|
||||||
|
<div class="episodeRow">
|
||||||
|
<img src="${ep.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
||||||
|
<div class="episodeInfo">
|
||||||
|
<strong>${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)}</strong>
|
||||||
|
<p class="hint">${escapeHtml(ep.overview || '')}</p>
|
||||||
|
</div>
|
||||||
|
<button class="startEpisodeBtn" data-item-id="${ep.id}">Lancer une watch party</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
listEl.querySelectorAll<HTMLButtonElement>('.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';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
16
client/tsconfig.json
Normal file
16
client/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -52,18 +52,28 @@ possible and this gets revisited.
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
```
|
```
|
||||||
server/ Node.js + Express + Socket.IO. Owns party/room state and the
|
server/ Node.js + Express + Socket.IO, TypeScript (compiled by tsc).
|
||||||
realtime sync channel. Never touches a media byte - see "Media
|
Proxies 100% of Emby access (catalog, images, HLS/audio
|
||||||
never flows through our backend" below.
|
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
|
client/ Frontend, TypeScript, esbuild-bundled to one app.js + one
|
||||||
as the earlier plugin's control panel). Talks to two different
|
app.css (client/dist) - no framework. Talks only to server/,
|
||||||
things and must not confuse them:
|
never to Emby directly (api/embyApi.ts and api/party.ts are
|
||||||
- Emby directly (embyApi.js): catalog, images, streaming.
|
both just fetch/Socket.IO wrappers around this app's own
|
||||||
- Our own backend (party.js): create/join/leave a party, and
|
/api/* routes). One folder per view, each colocating its own
|
||||||
the Socket.IO connection for realtime sync.
|
.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
|
### Auth and Emby access: the server proxies everything
|
||||||
|
|
||||||
Revised after the first version: the browser used to call Emby directly
|
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
|
observed), and the real audio quality/gapless-ness of playback through a
|
||||||
transcode. First things to check once this gets a real retest.
|
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 `<script type="module">` ordering
|
||||||
|
either) - esbuild was picked over a heavier tool (Vite, webpack)
|
||||||
|
because this app's whole client is a couple thousand lines across a
|
||||||
|
dozen files; esbuild's own built-in behaviors (bundling CSS reached
|
||||||
|
from JS/TS imports into a sibling `.css` file automatically, a `text`
|
||||||
|
loader for importing an HTML template as a string) cover everything
|
||||||
|
needed with a ~30-line hand-written build script
|
||||||
|
(`client/build.mjs`), not a config file for a tool built for much
|
||||||
|
bigger apps. Both are consistent with this project's running bias
|
||||||
|
against dependencies it doesn't need.
|
||||||
|
- Both projects compile **independently** - the server's `tsc` never
|
||||||
|
sees client code and vice versa. A real shared-types package was
|
||||||
|
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 copies of the DTO shapes instead (Party,
|
||||||
|
ItemDto, the music types, ...) - a normal, accepted trade-off for a
|
||||||
|
two-small-projects setup like this one.
|
||||||
|
- `npm run dev` in `server/` uses `tsx` (a single extra dev dependency)
|
||||||
|
to run straight from `.ts` with no separate compile step while
|
||||||
|
iterating; `npm run build` (`tsc`) is what actually ships, and what
|
||||||
|
`server/Dockerfile`'s build stage runs. The client's `npm run watch`
|
||||||
|
(esbuild's own watch mode) plays the same role there.
|
||||||
|
|
||||||
|
### Server: services, not route handlers with logic inline
|
||||||
|
|
||||||
|
The old `index.js` had ~15 route handlers with their actual logic - Emby
|
||||||
|
queries, DTO shaping, override-forcing - written directly inside the
|
||||||
|
`app.get(...)` callback. `server/src/index.ts` is now a thin wiring layer
|
||||||
|
only: every route reads the session, calls exactly one service method,
|
||||||
|
and returns what it gets back. The services, one singleton class per
|
||||||
|
feature (same shape `PartyService`/`MusicQueueService` already had before
|
||||||
|
this rewrite - this generalizes that pattern, it doesn't invent it):
|
||||||
|
|
||||||
|
- **`AuthService`** (from `embyAuth.js`) - login + the in-memory session store.
|
||||||
|
- **`CatalogService`** (new - previously inline in `index.js`) - every
|
||||||
|
Emby browsing/streaming call and the DTO shaping (`toItemDto`,
|
||||||
|
`applyPlaybackOverrides`) that used to be bare functions at the bottom
|
||||||
|
of the file. The single largest extraction.
|
||||||
|
- **`PartyService`** (renamed from `PartyManager`) - unchanged behavior,
|
||||||
|
now fully typed (see "A real type bug caught during the port" below).
|
||||||
|
- **`MusicQueueService`** - unchanged behavior, ported as-is (it was
|
||||||
|
already the newest, already-service-shaped code).
|
||||||
|
- **`SocketGateway`** (from `socketHandler.js`) - the realtime protocol.
|
||||||
|
Not stateful itself (no singleton *state*, unlike the four above) but
|
||||||
|
kept as a class for the same reason `Logger`/`EmbyClient` are: shape
|
||||||
|
consistency across `server/src`, not because every one of them
|
||||||
|
strictly needs to be a class.
|
||||||
|
- **`EmbyClient`** (from `embyProxy.js`) - low-level Emby HTTP +
|
||||||
|
response proxying. Deliberately *not* called a "service": it has no
|
||||||
|
state and isn't a feature on its own, just the shared plumbing
|
||||||
|
`CatalogService`'s routes are built on.
|
||||||
|
|
||||||
|
**Error handling got a real pass while this code was being touched
|
||||||
|
anyway.** Express 4 doesn't catch a rejected Promise from an `async`
|
||||||
|
route handler on its own - an unhandled rejection there would either
|
||||||
|
crash the process or hang the request, depending on Node version. Every
|
||||||
|
route is now wrapped in a small `asyncRoute()` helper
|
||||||
|
(`index.ts`) that forwards a thrown/rejected error to a single
|
||||||
|
error-handling middleware at the bottom of the file. `CatalogService`
|
||||||
|
methods throw a `CatalogFetchError` (carrying its own HTTP status -
|
||||||
|
e.g. `getRawItem` preserves Emby's real 404 instead of a blanket 502,
|
||||||
|
which the old code did per-route by hand) rather than touching an Express
|
||||||
|
response directly, so the same service method works whether it's called
|
||||||
|
from a route that has a `res` handy or (in principle) from anywhere else.
|
||||||
|
This wasn't a change asked for - it fell out of centralizing the routes
|
||||||
|
into services, and it's a real robustness improvement over the old
|
||||||
|
per-route `if (!embyRes.ok) { res.status(...).json(...); return; }`
|
||||||
|
repeated fifteen times with no handling at all for a request that
|
||||||
|
outright throws.
|
||||||
|
|
||||||
|
### Client: one folder per view, colocating .ts/.html/.css
|
||||||
|
|
||||||
|
Each view is now a small folder (`src/views/<name>/<Name>.ts` +
|
||||||
|
`.html` + `.css`) instead of one `.js` file with everything - including
|
||||||
|
the markup, as a big template literal - inline. The static shell of a
|
||||||
|
view's markup lives in its `.html` (imported as a raw string via
|
||||||
|
esbuild's `text` loader - see `global.d.ts`'s `declare module '*.html'`);
|
||||||
|
dynamic, per-item content (a tile grid, an episode list, a music queue)
|
||||||
|
still gets built as a template-literal-returning function inside the
|
||||||
|
`.ts`, same as before - only the outer, unchanging shell moved out.
|
||||||
|
Genuinely shared CSS (the tile grid, the presence-avatar circle, the
|
||||||
|
series/album detail header pattern, track-list rows) lives in small files
|
||||||
|
under `src/styles/`, imported by every `.ts` that needs it; `src/styles/base.css`
|
||||||
|
holds the truly global primitives (the theme's CSS variables, `button`/
|
||||||
|
`input`/`.linklike`, `.pageHeader`) and is imported exactly once, by
|
||||||
|
`app.ts`.
|
||||||
|
|
||||||
|
This is deliberately *not* pushed to the point of zero duplication or
|
||||||
|
class renaming across views that happen to share a visual pattern (the
|
||||||
|
series and album detail headers, for instance, keep their own
|
||||||
|
`seriesHeader`/`albumHeader` + cover-image classes, sharing only the
|
||||||
|
inner `seriesInfo`/`seriesYear`/`seriesOverview` block via
|
||||||
|
`styles/detail.css`) - renaming shared classes purely for tidiness would
|
||||||
|
have meant touching working, already-tested markup and CSS in lockstep
|
||||||
|
across files with no functional benefit, for no reason better than
|
||||||
|
aesthetics, in a change already large enough to review.
|
||||||
|
|
||||||
|
`Player.ts` keeps the same internal shape it had before this rewrite: one
|
||||||
|
file holding both the `/party/:id` route's dispatcher (`renderPartyPlayer`,
|
||||||
|
exported, registered in `app.ts`) and the video-specific renderer, which
|
||||||
|
imports `renderMusicPlayer` from the sibling `musicPlayer` folder - the
|
||||||
|
dispatcher pattern (fetch once, pick a renderer, hand back a synchronous
|
||||||
|
cleanup wrapper - see "Player: not theater-mode..." above) predates this
|
||||||
|
rewrite and didn't need to change shape, just types.
|
||||||
|
|
||||||
|
### A real type bug caught during the port
|
||||||
|
|
||||||
|
Porting `SocketGateway`'s `"members"` broadcast, it was tempting to reuse
|
||||||
|
the same `PublicMember` interface `PartyService.toPublicState()`'s REST
|
||||||
|
response uses. They're not the same shape: the REST response's
|
||||||
|
`members[]` includes `isPaused`/`positionTicks` per member; the socket
|
||||||
|
broadcast never did (a follower's player only ever reads *`"playstate"`*
|
||||||
|
events for position, never the member list, for that - see
|
||||||
|
`SocketGateway`'s original JS, which built a visibly smaller object
|
||||||
|
inline). Reusing the wrong interface would have type-checked fine (a
|
||||||
|
smaller object being assigned into a type asking for more fields is
|
||||||
|
exactly the class of mistake a structural type system like TypeScript's
|
||||||
|
*does* catch, but only if the two shapes are actually kept distinct) -
|
||||||
|
caught by comparing the ported code against the original JS line by line
|
||||||
|
rather than trusting the port "looked right", and fixed by giving the
|
||||||
|
socket broadcast its own `SocketMemberDto` type. Worth noting because
|
||||||
|
it's a fairly direct demonstration of what "clean TypeScript" is actually
|
||||||
|
for here: not decoration, a mistake this port itself introduced and then
|
||||||
|
caught before it shipped.
|
||||||
|
|
||||||
|
### A real Express+TypeScript friction point, resolved once
|
||||||
|
|
||||||
|
`tsconfig.json` for both projects enables `noUncheckedIndexedAccess` - a
|
||||||
|
stricter-than-`strict` flag that treats every indexed access
|
||||||
|
(`arr[i]`, and critically, Express's `req.params.foo`, which is typed as
|
||||||
|
an index signature) as possibly-`undefined`, to actually catch
|
||||||
|
out-of-bounds access. This is genuinely useful (`PartyService.advanceQueue`'s
|
||||||
|
`party.queue[party.queueIndex]` is exactly the kind of access this
|
||||||
|
catches if the index math is ever wrong) but makes every single
|
||||||
|
`req.params.xxx` read in `index.ts` a type error, since Express's own
|
||||||
|
types don't know a given route only ever matches with that param
|
||||||
|
present. Resolved once with a `param(req, name)` helper in `index.ts`
|
||||||
|
(asserts the value as `string` - a matched Express route with a required,
|
||||||
|
non-optional `:name` segment always has it, which none of these routes'
|
||||||
|
patterns violate) rather than either scattering non-null assertions at
|
||||||
|
every call site or writing out a params-generic interface per route for
|
||||||
|
15 routes.
|
||||||
|
|
||||||
|
### Verified
|
||||||
|
|
||||||
|
Both projects type-check cleanly in strict mode
|
||||||
|
(`npm run typecheck`/`tsc --noEmit`, no errors) - not just "compiles",
|
||||||
|
which `esbuild` would do even with type errors present, since it
|
||||||
|
transpiles without checking; the client's own separate `tsc --noEmit`
|
||||||
|
step is what actually guards against that gap.
|
||||||
|
|
||||||
|
Server: every unit/integration test written earlier for the JS version
|
||||||
|
(party lifecycle logging, the music queue's timer/vote/pause behavior
|
||||||
|
including real `setTimeout`-based auto-advance, the socket-level
|
||||||
|
integration through `SocketGateway`+`PartyService`+`MusicQueueService`
|
||||||
|
together, the library-type filter, the playback-override-forcing logic)
|
||||||
|
was re-run against the **compiled** `server/dist` output, not the
|
||||||
|
TypeScript source - every one passed unchanged (same assertions, same
|
||||||
|
counts). The compiled server was also actually started (`node dist/index.js`)
|
||||||
|
and confirmed listening cleanly, both directly and via `tsx` for the dev
|
||||||
|
path.
|
||||||
|
|
||||||
|
Client: since esbuild bundles everything into one `app.js` (no more
|
||||||
|
individually-importable view modules the way separate `<script
|
||||||
|
type="module">` files were before), testing moved from "import one view
|
||||||
|
and call it directly" to driving the **real, compiled bundle** through
|
||||||
|
its actual boot → login → router → view chain, with `fetch`/`window.io`/
|
||||||
|
`window.Hls` mocked and real clicks/hash-navigation - arguably a more
|
||||||
|
realistic test than the old per-module approach, not just an adaptation
|
||||||
|
forced by the bundler. Confirmed this way: login, the sidebar, home's
|
||||||
|
carousels, the movies library, a video party (presence, host controls,
|
||||||
|
leave), the full music hierarchy (library → artist → album), a music
|
||||||
|
party (presence, vote-skip, pause toggle, the inline browse-and-add
|
||||||
|
panel posting the right payload), and the series page - all through
|
||||||
|
`client/dist`'s actual output, no thrown errors anywhere in the sequence.
|
||||||
|
|
||||||
|
**Not verified**: an actual `docker build` of the new multi-stage
|
||||||
|
Dockerfile - this dev environment has no Docker installed at all. The
|
||||||
|
Dockerfile's build stage runs the exact same `npm run build` commands
|
||||||
|
already confirmed working locally for both projects, just inside
|
||||||
|
`node:20-alpine` instead of this machine, so the main remaining risk is
|
||||||
|
something environment-specific (a native dependency, a Node version
|
||||||
|
mismatch) rather than the build commands themselves being wrong - still,
|
||||||
|
a real build is the first thing to try after this lands, before assuming
|
||||||
|
the Dockerfile is right.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
- Playback always goes through `hls.js` against whatever Emby's
|
- Playback always goes through `hls.js` against whatever Emby's
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,31 @@
|
||||||
# Build context must be the repo root (not server/), since this also needs
|
# Build context must be the repo root (not server/), since this also needs
|
||||||
# the client/ folder: docker build -f server/Dockerfile -t watchparty .
|
# the client/ folder: docker build -f server/Dockerfile -t watchparty .
|
||||||
|
#
|
||||||
|
# Two stages: the first compiles both TypeScript projects (server/src -> JS
|
||||||
|
# via tsc, client/src -> a bundled JS+CSS via esbuild - see
|
||||||
|
# docs/architecture.md's TypeScript-migration section for why two separate
|
||||||
|
# toolchains rather than one shared build), the second is the actual
|
||||||
|
# runtime image, holding only the compiled output plus production
|
||||||
|
# dependencies - none of the TypeScript/esbuild toolchain itself ships.
|
||||||
|
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY server/package.json server/package-lock.json* ./server/
|
||||||
|
RUN cd server && npm install
|
||||||
|
COPY server/tsconfig.json ./server/
|
||||||
|
COPY server/src ./server/src
|
||||||
|
RUN cd server && npm run build
|
||||||
|
|
||||||
|
COPY client/package.json client/package-lock.json* ./client/
|
||||||
|
RUN cd client && npm install
|
||||||
|
COPY client/build.mjs ./client/
|
||||||
|
COPY client/tsconfig.json ./client/
|
||||||
|
COPY client/src ./client/src
|
||||||
|
RUN cd client && npm run build
|
||||||
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
@ -7,11 +33,11 @@ WORKDIR /app
|
||||||
COPY server/package.json server/package-lock.json* ./server/
|
COPY server/package.json server/package-lock.json* ./server/
|
||||||
RUN cd server && npm install --omit=dev
|
RUN cd server && npm install --omit=dev
|
||||||
|
|
||||||
COPY server ./server
|
COPY --from=build /app/server/dist ./server/dist
|
||||||
COPY client ./client
|
COPY --from=build /app/client/dist ./client/dist
|
||||||
|
|
||||||
WORKDIR /app/server
|
WORKDIR /app/server
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "src/index.js"]
|
CMD ["node", "dist/index.js"]
|
||||||
|
|
|
||||||
662
server/package-lock.json
generated
662
server/package-lock.json
generated
|
|
@ -11,6 +11,454 @@
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"socket.io": "^4.7.5"
|
"socket.io": "^4.7.5"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.14.2",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
|
|
@ -21,6 +469,27 @@
|
||||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/body-parser": {
|
||||||
|
"version": "1.19.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/connect": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/connect": {
|
||||||
|
"version": "3.4.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/cors": {
|
"node_modules/@types/cors": {
|
||||||
"version": "2.8.19",
|
"version": "2.8.19",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||||
|
|
@ -30,13 +499,100 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/express": {
|
||||||
"version": "26.5.1",
|
"version": "4.17.25",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
|
||||||
"integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==",
|
"integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~8.9.0"
|
"@types/body-parser": "*",
|
||||||
|
"@types/express-serve-static-core": "^4.17.33",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/serve-static": "^1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express-serve-static-core": {
|
||||||
|
"version": "4.19.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz",
|
||||||
|
"integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/range-parser": "*",
|
||||||
|
"@types/send": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/http-errors": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/mime": {
|
||||||
|
"version": "1.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
|
||||||
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "20.19.43",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
|
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/qs": {
|
||||||
|
"version": "6.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz",
|
||||||
|
"integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/range-parser": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static": {
|
||||||
|
"version": "1.15.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
|
||||||
|
"integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-errors": "*",
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/send": "<1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static/node_modules/@types/send": {
|
||||||
|
"version": "0.17.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
|
||||||
|
"integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mime": "^1",
|
||||||
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
|
|
@ -346,6 +902,48 @@
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||||
|
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.28.2",
|
||||||
|
"@esbuild/android-arm": "0.28.2",
|
||||||
|
"@esbuild/android-arm64": "0.28.2",
|
||||||
|
"@esbuild/android-x64": "0.28.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.28.2",
|
||||||
|
"@esbuild/darwin-x64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.28.2",
|
||||||
|
"@esbuild/linux-arm": "0.28.2",
|
||||||
|
"@esbuild/linux-arm64": "0.28.2",
|
||||||
|
"@esbuild/linux-ia32": "0.28.2",
|
||||||
|
"@esbuild/linux-loong64": "0.28.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.28.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.28.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.28.2",
|
||||||
|
"@esbuild/linux-s390x": "0.28.2",
|
||||||
|
"@esbuild/linux-x64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.28.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.28.2",
|
||||||
|
"@esbuild/openharmony-arm64": "0.28.2",
|
||||||
|
"@esbuild/sunos-x64": "0.28.2",
|
||||||
|
"@esbuild/win32-arm64": "0.28.2",
|
||||||
|
"@esbuild/win32-ia32": "0.28.2",
|
||||||
|
"@esbuild/win32-x64": "0.28.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
|
|
@ -443,6 +1041,21 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/function-bind": {
|
"node_modules/function-bind": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
|
|
@ -1034,6 +1647,25 @@
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tsx": {
|
||||||
|
"version": "4.23.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.13.tgz",
|
||||||
|
"integrity": "sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "~0.28.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tsx": "dist/cli.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/type-is": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
|
@ -1047,10 +1679,24 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.9.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "commonjs",
|
"type": "commonjs",
|
||||||
"main": "src/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"build": "tsc",
|
||||||
"dev": "node src/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|
@ -14,5 +16,11 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"socket.io": "^4.7.5"
|
"socket.io": "^4.7.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^20.14.2",
|
||||||
|
"tsx": "^4.16.2",
|
||||||
|
"typescript": "^5.5.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
server/src/Logger.ts
Normal file
44
server/src/Logger.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal server-side logging - plain stdout lines, no logging library.
|
||||||
|
* This app runs as a single Docker container watched via `docker logs`/
|
||||||
|
* journalctl (see README's Deploying section), not behind a log
|
||||||
|
* aggregator, so a consistent prefix + timestamp is what actually makes
|
||||||
|
* these greppable in practice, not structured JSON nobody's parsing.
|
||||||
|
*
|
||||||
|
* Static methods rather than a singleton instance: there's no state here
|
||||||
|
* at all (unlike PartyService/MusicQueueService, which really do own
|
||||||
|
* something) - a class is just a namespace for these two, kept as a class
|
||||||
|
* for consistency with the rest of server/src/services' shape.
|
||||||
|
*/
|
||||||
|
export class Logger {
|
||||||
|
/**
|
||||||
|
* Party business-lifecycle events - created/joined/left/destroyed, host
|
||||||
|
* handoff, queue advance, playstate transitions, pause/skip requests.
|
||||||
|
* Deliberately covers only that, not connection-level noise like every
|
||||||
|
* Socket.IO reconnect or the 3s playstate heartbeat re-sending the same
|
||||||
|
* state (see SocketGateway, which only calls this on an actual
|
||||||
|
* play/pause transition, not every heartbeat tick).
|
||||||
|
*/
|
||||||
|
static party(message: string): void {
|
||||||
|
console.log(`[watchparty:party] ${new Date().toISOString()} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The streaming route specifically (CatalogService's /master.m3u8
|
||||||
|
* handler) - a different concern from the party lifecycle above (it's
|
||||||
|
* about one HTTP request, not party state). Deliberately logs only the
|
||||||
|
* individual override values it cares about, never the full resolved
|
||||||
|
* URL or params blob - Emby's own TranscodingUrl can carry an api_key,
|
||||||
|
* and that has no business ending up in a log file.
|
||||||
|
*/
|
||||||
|
static stream(message: string): void {
|
||||||
|
console.log(`[watchparty:stream] ${new Date().toISOString()} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `"name(id)"` - the same shape in every party lifecycle log line. */
|
||||||
|
static who(userId: string, userName: string): string {
|
||||||
|
return `${userName}(${userId})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
server/src/config.ts
Normal file
19
server/src/config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Environment-derived config, read once at startup. Its own tiny module
|
||||||
|
* (rather than living inside AuthService, which used to own
|
||||||
|
* EMBY_SERVER_URL) since both AuthService and CatalogService/EmbyClient
|
||||||
|
* need it, and there's no reason CatalogService should have to depend on
|
||||||
|
* AuthService just to read a URL.
|
||||||
|
*/
|
||||||
|
export const EMBY_SERVER_URL = (process.env.EMBY_SERVER_URL || '').replace(/\/+$/, '');
|
||||||
|
|
||||||
|
if (!EMBY_SERVER_URL) {
|
||||||
|
console.warn(
|
||||||
|
'[watchparty] EMBY_SERVER_URL is not set - every Emby call will fail. ' +
|
||||||
|
'Set it to your Emby server base URL, e.g. https://theater.media.kyuno.fr'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PORT = Number(process.env.PORT) || 3000;
|
||||||
88
server/src/emby/EmbyClient.ts
Normal file
88
server/src/emby/EmbyClient.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { EMBY_SERVER_URL } from '../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Low-level HTTP plumbing for talking to Emby and proxying its responses
|
||||||
|
* back to the browser - not a "feature" service itself (no state, nothing
|
||||||
|
* a client action changes), just the shared mechanics CatalogService's
|
||||||
|
* routes are built on. See docs/architecture.md for why the server proxies
|
||||||
|
* 100% of Emby access instead of the browser talking to Emby directly.
|
||||||
|
*/
|
||||||
|
export class EmbyClient {
|
||||||
|
/** Fetches a path from Emby with the given user's token, forwarding
|
||||||
|
* Range (needed for direct-play video seeking) when the browser sent one. */
|
||||||
|
static async fetch(pathAndQuery: string, token: string, options: { range?: string } = {}): Promise<globalThis.Response> {
|
||||||
|
const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`;
|
||||||
|
const headers: Record<string, string> = { 'X-Emby-Token': token };
|
||||||
|
if (options.range) headers.Range = options.range;
|
||||||
|
return fetch(url, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
static isPlaylistPath(path: string): boolean {
|
||||||
|
return /\.m3u8(\?|$)/i.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrites every URI line in an HLS playlist so it routes back through
|
||||||
|
* our own proxy (`/api/catalog/stream/:itemId/raw?u=...`) instead of
|
||||||
|
* Emby directly - whether Emby wrote that line as a relative or an
|
||||||
|
* absolute URL. Without this, hls.js would follow the playlist's own
|
||||||
|
* URLs straight to Emby, defeating the point of proxying the master
|
||||||
|
* playlist in the first place. TODO(verify): only tested against
|
||||||
|
* synthetic input, not a real Emby-generated playlist - see
|
||||||
|
* docs/architecture.md.
|
||||||
|
*/
|
||||||
|
static rewritePlaylist(text: string, itemId: string): string {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) return line;
|
||||||
|
return `/api/catalog/stream/${itemId}/raw?u=${encodeURIComponent(trimmed)}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxies an Emby response to an Express response: forwards status,
|
||||||
|
* content-type, and the headers that make byte-range seeking work
|
||||||
|
* (content-range/accept-ranges), rewriting HLS playlists in transit and
|
||||||
|
* streaming everything else (images, video/audio segments) through
|
||||||
|
* untouched - no buffering, so this doesn't hold a whole segment in memory.
|
||||||
|
*/
|
||||||
|
static async proxyResponse(req: Request, res: Response, pathAndQuery: string, token: string, itemId: string): Promise<void> {
|
||||||
|
let embyRes: globalThis.Response;
|
||||||
|
try {
|
||||||
|
embyRes = await EmbyClient.fetch(pathAndQuery, token, { range: req.headers.range });
|
||||||
|
} catch {
|
||||||
|
res.status(502).json({ error: 'Serveur Emby injoignable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(embyRes.status);
|
||||||
|
const contentType = embyRes.headers.get('content-type') || '';
|
||||||
|
if (contentType) res.setHeader('Content-Type', contentType);
|
||||||
|
for (const h of ['content-range', 'accept-ranges']) {
|
||||||
|
const v = embyRes.headers.get(h);
|
||||||
|
if (v) res.setHeader(h, v);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (EmbyClient.isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
|
||||||
|
const text = await embyRes.text();
|
||||||
|
res.send(EmbyClient.rewritePlaylist(text, itemId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLength = embyRes.headers.get('content-length');
|
||||||
|
if (contentLength) res.setHeader('Content-Length', contentLength);
|
||||||
|
|
||||||
|
if (!embyRes.body) {
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Readable.fromWeb(embyRes.body as import('stream/web').ReadableStream<Uint8Array>).pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
|
|
||||||
const EMBY_SERVER_URL = (process.env.EMBY_SERVER_URL || '').replace(/\/+$/, '');
|
|
||||||
|
|
||||||
if (!EMBY_SERVER_URL) {
|
|
||||||
console.warn(
|
|
||||||
'[watchparty] EMBY_SERVER_URL is not set - every Emby call will fail. ' +
|
|
||||||
'Set it to your Emby server base URL, e.g. https://theater.media.kyuno.fr'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_COOKIE = 'wp_session';
|
|
||||||
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
|
||||||
|
|
||||||
// sessionId -> { embyToken, userId, userName, createdAt }
|
|
||||||
// In-memory, like party state - a restart signs everyone out, which is fine.
|
|
||||||
const sessions = new Map();
|
|
||||||
|
|
||||||
function authHeaderValue() {
|
|
||||||
return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Signs in against Emby *from the server*. The browser only ever sends its
|
|
||||||
* username/password once, to us; we forward that to Emby, and from then on
|
|
||||||
* the browser only holds an opaque session id (cookie) - never Emby's own
|
|
||||||
* access token. This is what makes "the server proxies everything" possible:
|
|
||||||
* the browser has nothing it could use to talk to Emby even if it tried.
|
|
||||||
*
|
|
||||||
* Returns a new session id, or null on bad credentials / unreachable Emby.
|
|
||||||
*/
|
|
||||||
async function login(username, password) {
|
|
||||||
if (!EMBY_SERVER_URL) return null;
|
|
||||||
|
|
||||||
let res;
|
|
||||||
try {
|
|
||||||
res = await fetch(`${EMBY_SERVER_URL}/Users/AuthenticateByName`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Emby-Authorization': authHeaderValue()
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ Username: username, Pw: password })
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[watchparty] Emby login request failed:', err.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
const sessionId = crypto.randomUUID();
|
|
||||||
sessions.set(sessionId, {
|
|
||||||
embyToken: data.AccessToken,
|
|
||||||
userId: data.User.Id,
|
|
||||||
userName: data.User.Name,
|
|
||||||
serverId: data.ServerId, // needed to build "open in Emby" deep links
|
|
||||||
createdAt: Date.now()
|
|
||||||
});
|
|
||||||
return sessionId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSession(sessionId) {
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
if (!session) return null;
|
|
||||||
if (Date.now() - session.createdAt > SESSION_TTL_MS) {
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
|
|
||||||
function endSession(sessionId) {
|
|
||||||
sessions.delete(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCookies(cookieHeader) {
|
|
||||||
const out = {};
|
|
||||||
(cookieHeader || '').split(';').forEach((part) => {
|
|
||||||
const idx = part.indexOf('=');
|
|
||||||
if (idx === -1) return;
|
|
||||||
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
|
||||||
});
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the caller's session from an Express request's cookie, or null. */
|
|
||||||
function sessionFromRequest(req) {
|
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
|
||||||
const id = cookies[SESSION_COOKIE];
|
|
||||||
return id ? getSession(id) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
EMBY_SERVER_URL,
|
|
||||||
SESSION_COOKIE,
|
|
||||||
SESSION_TTL_MS,
|
|
||||||
login,
|
|
||||||
getSession,
|
|
||||||
endSession,
|
|
||||||
parseCookies,
|
|
||||||
sessionFromRequest
|
|
||||||
};
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { Readable } = require('stream');
|
|
||||||
const { EMBY_SERVER_URL } = require('./embyAuth');
|
|
||||||
|
|
||||||
/** Fetches a path from Emby with the given user's token, forwarding Range
|
|
||||||
* (needed for direct-play video seeking) when the browser sent one. */
|
|
||||||
async function embyFetch(pathAndQuery, token, { range } = {}) {
|
|
||||||
const url = pathAndQuery.startsWith('http') ? pathAndQuery : `${EMBY_SERVER_URL}${pathAndQuery}`;
|
|
||||||
const headers = { 'X-Emby-Token': token };
|
|
||||||
if (range) headers.Range = range;
|
|
||||||
return fetch(url, { headers });
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlaylistPath(path) {
|
|
||||||
return /\.m3u8(\?|$)/i.test(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rewrites every URI line in an HLS playlist so it routes back through our
|
|
||||||
* own proxy (`/api/catalog/stream/:itemId/raw?u=...`) instead of Emby
|
|
||||||
* directly - whether Emby wrote that line as a relative or an absolute URL.
|
|
||||||
* Without this, hls.js would follow the playlist's own URLs straight to
|
|
||||||
* Emby, defeating the point of proxying the master playlist in the first
|
|
||||||
* place. TODO(verify): only tested against synthetic input, not a real
|
|
||||||
* Emby-generated playlist - see docs/architecture.md.
|
|
||||||
*/
|
|
||||||
function rewritePlaylist(text, itemId) {
|
|
||||||
return text
|
|
||||||
.split('\n')
|
|
||||||
.map((line) => {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith('#')) return line;
|
|
||||||
return `/api/catalog/stream/${itemId}/raw?u=${encodeURIComponent(trimmed)}`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Proxies an Emby response to an Express response: forwards status,
|
|
||||||
* content-type, and the headers that make byte-range seeking work
|
|
||||||
* (content-range/accept-ranges), rewriting HLS playlists in transit and
|
|
||||||
* streaming everything else (images, video segments) through untouched -
|
|
||||||
* no buffering, so this doesn't hold a whole video segment in memory.
|
|
||||||
*/
|
|
||||||
async function proxyEmbyResponse(req, res, pathAndQuery, token, itemId) {
|
|
||||||
let embyRes;
|
|
||||||
try {
|
|
||||||
embyRes = await embyFetch(pathAndQuery, token, { range: req.headers.range });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(502).json({ error: 'Serveur Emby injoignable.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(embyRes.status);
|
|
||||||
const contentType = embyRes.headers.get('content-type') || '';
|
|
||||||
if (contentType) res.setHeader('Content-Type', contentType);
|
|
||||||
for (const h of ['content-range', 'accept-ranges']) {
|
|
||||||
const v = embyRes.headers.get(h);
|
|
||||||
if (v) res.setHeader(h, v);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isPlaylistPath(pathAndQuery) || contentType.includes('mpegurl')) {
|
|
||||||
const text = await embyRes.text();
|
|
||||||
res.send(rewritePlaylist(text, itemId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = embyRes.headers.get('content-length');
|
|
||||||
if (contentLength) res.setHeader('Content-Length', contentLength);
|
|
||||||
|
|
||||||
if (!embyRes.body) {
|
|
||||||
res.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Readable.fromWeb(embyRes.body).pipe(res);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { embyFetch, proxyEmbyResponse, isPlaylistPath };
|
|
||||||
|
|
@ -1,623 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const path = require('path');
|
|
||||||
const http = require('http');
|
|
||||||
const express = require('express');
|
|
||||||
const { Server: SocketIoServer } = require('socket.io');
|
|
||||||
|
|
||||||
const {
|
|
||||||
EMBY_SERVER_URL,
|
|
||||||
SESSION_COOKIE,
|
|
||||||
SESSION_TTL_MS,
|
|
||||||
login,
|
|
||||||
getSession,
|
|
||||||
endSession,
|
|
||||||
parseCookies,
|
|
||||||
sessionFromRequest
|
|
||||||
} = require('./embyAuth');
|
|
||||||
const { embyFetch, proxyEmbyResponse } = require('./embyProxy');
|
|
||||||
const { PartyManager } = require('./partyManager');
|
|
||||||
const { MusicQueueService } = require('./musicQueueService');
|
|
||||||
const { attachSocketServer, broadcastLobby, applyLeave } = require('./socketHandler');
|
|
||||||
const { streamLog } = require('./logger');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
// Everything the browser needs - the app shell AND every Emby call - is
|
|
||||||
// same-origin from here on. See docs/architecture.md for why: the previous
|
|
||||||
// version had the browser call Emby directly and hit real CORS problems.
|
|
||||||
app.use(express.static(path.join(__dirname, '..', '..', 'client')));
|
|
||||||
|
|
||||||
function requireSession(req, res, next) {
|
|
||||||
const session = sessionFromRequest(req);
|
|
||||||
if (!session) {
|
|
||||||
res.status(401).json({ error: 'Session expirée, reconnecte-toi.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
req.embySession = session;
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Auth ----------------------------------------------------------------
|
|
||||||
// The browser only ever sends a username/password once, to us. From then on
|
|
||||||
// it holds an opaque session cookie - never Emby's own access token.
|
|
||||||
|
|
||||||
app.post('/api/auth/login', async (req, res) => {
|
|
||||||
const { username, password } = req.body || {};
|
|
||||||
if (!username || !password) {
|
|
||||||
res.status(400).json({ error: "Nom d'utilisateur et mot de passe requis." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = await login(username, password);
|
|
||||||
if (!sessionId) {
|
|
||||||
res.status(401).json({ error: 'Identifiants refusés.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.cookie(SESSION_COOKIE, sessionId, {
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: 'lax',
|
|
||||||
secure: req.secure,
|
|
||||||
maxAge: SESSION_TTL_MS
|
|
||||||
});
|
|
||||||
res.json({ userName: getSession(sessionId).userName });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/auth/logout', (req, res) => {
|
|
||||||
const cookies = parseCookies(req.headers.cookie);
|
|
||||||
if (cookies[SESSION_COOKIE]) {
|
|
||||||
endSession(cookies[SESSION_COOKIE]);
|
|
||||||
}
|
|
||||||
res.clearCookie(SESSION_COOKIE);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/auth/me', requireSession, (req, res) => {
|
|
||||||
res.json({ userName: req.embySession.userName });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Catalog (proxied - the browser never talks to Emby directly) --------
|
|
||||||
|
|
||||||
app.get('/api/catalog/search', requireSession, async (req, res) => {
|
|
||||||
const q = (req.query.q || '').trim();
|
|
||||||
if (q.length < 2) {
|
|
||||||
res.json({ items: [] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
searchTerm: q,
|
|
||||||
IncludeItemTypes: 'Movie,Episode,Series',
|
|
||||||
Recursive: 'true',
|
|
||||||
Fields: 'Overview,ProductionYear',
|
|
||||||
ImageTypeLimit: '1',
|
|
||||||
Limit: '24'
|
|
||||||
});
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Recherche impossible côté Emby.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => {
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${req.params.itemId}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(embyRes.status).json({ error: 'Titre introuvable.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json(toItemDto(await embyRes.json(), req.embySession.serverId));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emby's /Views can return all sorts of things beyond actual media
|
|
||||||
// libraries - boxset collections, playlists, live TV, photos, books, a
|
|
||||||
// mixed-content folder with no CollectionType at all - none of which this
|
|
||||||
// app has any UI for. Only these four are ever shown, in the sidebar or
|
|
||||||
// anywhere else: everything else is dropped here, at the source, so every
|
|
||||||
// consumer (shell.js's sidebar, home.js's carousels, direct navigation to
|
|
||||||
// /library/:id) is filtered the same way without each having to repeat the
|
|
||||||
// same check.
|
|
||||||
const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']);
|
|
||||||
|
|
||||||
// One entry per library, shown in the sidebar - see client/js/shell.js.
|
|
||||||
app.get('/api/catalog/libraries', requireSession, async (req, res) => {
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Views`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer les bibliothèques.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({
|
|
||||||
libraries: (data.Items || [])
|
|
||||||
.filter((v) => VISIBLE_COLLECTION_TYPES.has(v.CollectionType))
|
|
||||||
.map((v) => ({
|
|
||||||
id: v.Id,
|
|
||||||
name: v.Name,
|
|
||||||
// "movies" / "tvshows" / "music" / "musicvideos" - drives which
|
|
||||||
// browsing experience client/js/views/library.js renders. See
|
|
||||||
// docs/architecture.md.
|
|
||||||
collectionType: v.CollectionType
|
|
||||||
}))
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Home page carousel: Emby's own "recently added" endpoint, one row per library.
|
|
||||||
app.get('/api/catalog/libraries/:libraryId/recent', requireSession, async (req, res) => {
|
|
||||||
const params = new URLSearchParams({ ParentId: req.params.libraryId, Limit: '16', Fields: 'ProductionYear' });
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/Latest?${params}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer les ajouts récents.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
|
|
||||||
const items = Array.isArray(data) ? data : (data.Items || []);
|
|
||||||
res.json({ items: items.map((i) => toItemDto(i, req.embySession.serverId)) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Series -> Seasons -> Episodes, for the "Contenus vidéo en série" browsing page.
|
|
||||||
app.get('/api/catalog/series/:seriesId/seasons', requireSession, async (req, res) => {
|
|
||||||
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Seasons?userId=${req.embySession.userId}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer les saisons.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({
|
|
||||||
seasons: (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber }))
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/catalog/series/:seriesId/episodes', requireSession, async (req, res) => {
|
|
||||||
const params = new URLSearchParams({ userId: req.embySession.userId, Fields: 'Overview' });
|
|
||||||
if (req.query.seasonId) params.set('SeasonId', req.query.seasonId);
|
|
||||||
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Episodes?${params}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer les épisodes.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing pages
|
|
||||||
// (client/js/views/library.js, artist.js, album.js). Each its own Emby
|
|
||||||
// endpoint/query rather than guessing folder structure, same reasoning as
|
|
||||||
// Shows -> Seasons -> Episodes above.
|
|
||||||
app.get('/api/catalog/libraries/:libraryId/artists', requireSession, async (req, res) => {
|
|
||||||
const params = new URLSearchParams({ ParentId: req.params.libraryId, UserId: req.embySession.userId, SortBy: 'SortName' });
|
|
||||||
const embyRes = await embyFetch(`/Artists?${params}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer les artistes.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({ artists: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/catalog/artists/:artistId/albums', requireSession, async (req, res) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
ArtistIds: req.params.artistId,
|
|
||||||
UserId: req.embySession.userId,
|
|
||||||
IncludeItemTypes: 'MusicAlbum',
|
|
||||||
Recursive: 'true',
|
|
||||||
SortBy: 'ProductionYear,SortName',
|
|
||||||
Fields: 'ProductionYear'
|
|
||||||
});
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer les albums.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({ albums: (data.Items || []).map((a) => toItemDto(a, req.embySession.serverId)) });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/catalog/albums/:albumId/tracks', requireSession, async (req, res) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
ParentId: req.params.albumId,
|
|
||||||
UserId: req.embySession.userId,
|
|
||||||
IncludeItemTypes: 'Audio',
|
|
||||||
SortBy: 'IndexNumber',
|
|
||||||
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
|
|
||||||
});
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer les morceaux.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({ tracks: (data.Items || []).map((t) => toItemDto(t, req.embySession.serverId)) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Audio/subtitle tracks available for an item, read from Emby's own playback
|
|
||||||
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
|
|
||||||
app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => {
|
|
||||||
const infoRes = await embyFetch(`/Items/${req.params.itemId}/PlaybackInfo?UserId=${req.embySession.userId}`, req.embySession.embyToken);
|
|
||||||
if (!infoRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de lire les options de lecture.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const info = await infoRes.json();
|
|
||||||
const source = (info.MediaSources || [])[0];
|
|
||||||
const streams = (source && source.MediaStreams) || [];
|
|
||||||
res.json({
|
|
||||||
audioTracks: streams
|
|
||||||
.filter((s) => s.Type === 'Audio')
|
|
||||||
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` })),
|
|
||||||
subtitleTracks: streams
|
|
||||||
.filter((s) => s.Type === 'Subtitle')
|
|
||||||
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` }))
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/catalog/libraries/:libraryId/items', requireSession, async (req, res) => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
ParentId: req.params.libraryId,
|
|
||||||
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
|
|
||||||
SortBy: 'SortName',
|
|
||||||
Fields: 'Overview,ProductionYear',
|
|
||||||
ImageTypeLimit: '1',
|
|
||||||
Limit: '300'
|
|
||||||
});
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de récupérer le contenu de cette bibliothèque.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await embyRes.json();
|
|
||||||
res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Images/video are requested by <img>/<video> elements, which send the
|
|
||||||
// session cookie (same-origin) but can't carry a bearer header - so these
|
|
||||||
// two routes check the cookie directly instead of using the requireSession
|
|
||||||
// middleware's JSON error response, which a browser media element ignores.
|
|
||||||
|
|
||||||
app.get('/api/catalog/image/:itemId', async (req, res) => {
|
|
||||||
const session = sessionFromRequest(req);
|
|
||||||
if (!session) { res.status(401).end(); return; }
|
|
||||||
await proxyEmbyResponse(req, res, `/Items/${req.params.itemId}/Images/Primary?maxWidth=400`, session.embyToken, req.params.itemId);
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
|
|
||||||
const session = sessionFromRequest(req);
|
|
||||||
if (!session) { res.status(401).end(); return; }
|
|
||||||
const itemId = req.params.itemId;
|
|
||||||
|
|
||||||
// Ask Emby how it wants this played rather than hand-building a
|
|
||||||
// /Videos/{id}/master.m3u8 URL ourselves: the first version did that with
|
|
||||||
// only VideoCodec/AudioCodec, and Emby's server threw "Value cannot be
|
|
||||||
// null (Parameter 'key')" - almost certainly an internal dictionary
|
|
||||||
// lookup (MediaSourceId/PlaySessionId/DeviceId) that URL never supplied.
|
|
||||||
// PlaybackInfo returns a MediaSource with its own ready-to-use
|
|
||||||
// TranscodingUrl carrying whatever Emby actually needs.
|
|
||||||
//
|
|
||||||
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate (and
|
|
||||||
// SubtitleMethod=Encode, forced by applyPlaybackOverrides whenever a
|
|
||||||
// subtitle is requested - see its own comment below) come from the
|
|
||||||
// player's language/subtitle/quality pickers, applied to the negotiation
|
|
||||||
// itself, not just the final URL. That distinction matters here: whether
|
|
||||||
// to actually re-encode the video (required to burn in a subtitle) is a
|
|
||||||
// decision Emby makes *during* PlaybackInfo, not something a later
|
|
||||||
// query-string tweak on the resulting URL can retroactively change if
|
|
||||||
// Emby already set up a plain remux. Still not trusted to have taken
|
|
||||||
// effect on its own, though (this app never sends the
|
|
||||||
// POST-with-DeviceProfile body real Emby web clients use for that
|
|
||||||
// negotiation, so there's no guarantee a plain GET applies it the same
|
|
||||||
// way) - applyPlaybackOverrides is applied a second time below, to
|
|
||||||
// whatever URL we end up with, so a track pick can't silently no-op
|
|
||||||
// regardless of which path Emby's response takes.
|
|
||||||
const infoParams = new URLSearchParams({ UserId: session.userId });
|
|
||||||
applyPlaybackOverrides(infoParams, req.query);
|
|
||||||
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session.embyToken);
|
|
||||||
if (!infoRes.ok) {
|
|
||||||
res.status(502).json({ error: 'Impossible de préparer la lecture de ce titre.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const info = await infoRes.json();
|
|
||||||
const source = (info.MediaSources || [])[0];
|
|
||||||
if (!source) {
|
|
||||||
res.status(502).json({ error: 'Aucune source de lecture disponible pour ce titre.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(verify): TranscodingUrl not confirmed present for every source
|
|
||||||
// type/Emby version (e.g. a source Emby considers direct-playable may not
|
|
||||||
// have one at all) - the hand-built fallback below now at least supplies
|
|
||||||
// the parameters PlaybackInfo reveals (MediaSourceId, PlaySessionId,
|
|
||||||
// UserId) instead of guessing, but is still a fallback, not the primary path.
|
|
||||||
// Music tracks stream through this exact route too (same PlaybackInfo ->
|
|
||||||
// TranscodingUrl dance - Emby's negotiation isn't video-specific) - the
|
|
||||||
// only place that actually differs is the hand-built fallback path,
|
|
||||||
// which is genuinely different between the two (/Videos/ vs /Audio/) and
|
|
||||||
// has no way to know which without being told: PlaybackInfo's own
|
|
||||||
// response doesn't reliably carry the item's Type back, and looking it
|
|
||||||
// up would be an extra Emby round trip for what the client already
|
|
||||||
// knows. So the client passes mediaType=audio (see streamUrl() in
|
|
||||||
// embyApi.js) - cosmetic in every other sense, this is the one place it
|
|
||||||
// actually changes what gets requested.
|
|
||||||
const isAudio = req.query.mediaType === 'audio';
|
|
||||||
let target;
|
|
||||||
if (source.TranscodingUrl) {
|
|
||||||
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
|
|
||||||
const params = new URLSearchParams(urlQuery || '');
|
|
||||||
applyPlaybackOverrides(params, req.query);
|
|
||||||
target = `${urlPath}?${params}`;
|
|
||||||
logStreamRequest(itemId, 'negotiated', params);
|
|
||||||
} else {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
UserId: session.userId,
|
|
||||||
MediaSourceId: source.Id || '',
|
|
||||||
PlaySessionId: info.PlaySessionId || '',
|
|
||||||
AudioCodec: 'aac',
|
|
||||||
...(isAudio ? {} : { VideoCodec: 'h264' })
|
|
||||||
});
|
|
||||||
applyPlaybackOverrides(params, req.query);
|
|
||||||
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
|
|
||||||
logStreamRequest(itemId, 'fallback', params);
|
|
||||||
}
|
|
||||||
|
|
||||||
await proxyEmbyResponse(req, res, target, session.embyToken, itemId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deliberately logs only these specific override values, never the full
|
|
||||||
// resolved URL/params blob - Emby's own TranscodingUrl can carry an
|
|
||||||
// api_key, and that has no business ending up in a log file. Added
|
|
||||||
// alongside the language/subtitle fix above so it's actually possible to
|
|
||||||
// confirm from docker logs/journalctl whether a requested override made it
|
|
||||||
// into the request Emby received, without a devtools session on whatever
|
|
||||||
// machine is running the server.
|
|
||||||
function logStreamRequest(itemId, source, params) {
|
|
||||||
streamLog(
|
|
||||||
`master.m3u8 itemId=${itemId} source=${source} ` +
|
|
||||||
`audio=${params.get('AudioStreamIndex') || '(default)'} ` +
|
|
||||||
`subtitle=${params.get('SubtitleStreamIndex') || '(none)'} ` +
|
|
||||||
`subtitleMethod=${params.get('SubtitleMethod') || '(none)'} ` +
|
|
||||||
`quality=${params.get('MaxStreamingBitrate') || '(auto)'}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Forces this app's own audio/subtitle/quality selection onto a transcode
|
|
||||||
* URL, whether that URL came from Emby's own PlaybackInfo negotiation or
|
|
||||||
* was hand-built as a fallback - see the route above. Without this, a pick
|
|
||||||
* from the player's settings menu could silently do nothing if Emby's
|
|
||||||
* negotiation didn't apply the query-string override the same way its own
|
|
||||||
* web client's POST-with-DeviceProfile request would have.
|
|
||||||
*
|
|
||||||
* SubtitleMethod=Encode is forced whenever a subtitle track is requested:
|
|
||||||
* left to its own default, Emby may instead deliver text subtitles as a
|
|
||||||
* separate selectable HLS rendition (SubtitleMethod=Hls) rather than
|
|
||||||
* burning them into the video - and this player never wires up hls.js's
|
|
||||||
* subtitleTrack API to pick one, so a rendition-based subtitle would be
|
|
||||||
* present in the manifest but never actually shown. Encode guarantees the
|
|
||||||
* subtitle is part of the one video stream this player already renders.
|
|
||||||
* Picking "Aucun" removes any subtitle Emby's own negotiation added, rather
|
|
||||||
* than just not adding a new one - otherwise turning subtitles off from a
|
|
||||||
* state where Emby had already picked a default would be a no-op too.
|
|
||||||
*/
|
|
||||||
function applyPlaybackOverrides(params, query) {
|
|
||||||
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
|
|
||||||
if (query.SubtitleStreamIndex) {
|
|
||||||
params.set('SubtitleStreamIndex', query.SubtitleStreamIndex);
|
|
||||||
params.set('SubtitleMethod', 'Encode');
|
|
||||||
} else {
|
|
||||||
params.delete('SubtitleStreamIndex');
|
|
||||||
params.delete('SubtitleMethod');
|
|
||||||
}
|
|
||||||
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Everything an HLS playlist references (variant playlists, segments) comes
|
|
||||||
// back through here - see embyProxy.rewritePlaylist.
|
|
||||||
app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
|
|
||||||
const session = sessionFromRequest(req);
|
|
||||||
if (!session) { res.status(401).end(); return; }
|
|
||||||
|
|
||||||
const target = req.query.u || '';
|
|
||||||
const itemId = req.params.itemId;
|
|
||||||
let pathAndQuery;
|
|
||||||
if (/^https?:\/\//i.test(target)) {
|
|
||||||
if (!target.startsWith(EMBY_SERVER_URL)) {
|
|
||||||
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pathAndQuery = target;
|
|
||||||
} else {
|
|
||||||
pathAndQuery = `/Videos/${itemId}/${target}`;
|
|
||||||
}
|
|
||||||
await proxyEmbyResponse(req, res, pathAndQuery, session.embyToken, itemId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
|
||||||
// contents (distinct from the generic 'Video', which is home videos) -
|
|
||||||
// needed now that musicvideos is one of the visible library types (see
|
|
||||||
// VISIBLE_COLLECTION_TYPES above), otherwise every tile in it would render
|
|
||||||
// with no "Lancer une watch party" button at all. 'Audio' is a music track.
|
|
||||||
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
|
|
||||||
|
|
||||||
function toItemDto(item, serverId) {
|
|
||||||
return {
|
|
||||||
id: item.Id,
|
|
||||||
name: item.Name,
|
|
||||||
overview: item.Overview,
|
|
||||||
year: item.ProductionYear,
|
|
||||||
type: item.Type,
|
|
||||||
// Series/Season/MusicArtist/MusicAlbum aren't directly playable - only
|
|
||||||
// show "start a party" for things Emby can actually stream. "Voir sur
|
|
||||||
// Emby" always works though.
|
|
||||||
canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
|
|
||||||
imageUrl: `/api/catalog/image/${item.Id}`,
|
|
||||||
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
|
|
||||||
// Track-specific (undefined/null for anything else - harmless, every
|
|
||||||
// consumer that cares about these only reads them for an Audio item).
|
|
||||||
durationTicks: item.RunTimeTicks || null,
|
|
||||||
indexNumber: item.IndexNumber != null ? item.IndexNumber : null,
|
|
||||||
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
|
|
||||||
albumName: item.Album || null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Parties ---------------------------------------------------------------
|
|
||||||
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
|
||||||
// list is live, over Socket.IO's "lobby" room (socketHandler.js) - see
|
|
||||||
// docs/architecture.md. No join codes either - see partyManager.js's class
|
|
||||||
// comment for why a live list makes one redundant.
|
|
||||||
|
|
||||||
app.post('/api/parties', requireSession, async (req, res) => {
|
|
||||||
const itemId = req.body && req.body.itemId;
|
|
||||||
if (!itemId) {
|
|
||||||
res.status(400).json({ error: 'itemId is required.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${itemId}`, req.embySession.embyToken);
|
|
||||||
if (!embyRes.ok) {
|
|
||||||
res.status(404).json({ error: 'Titre introuvable.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const item = await embyRes.json();
|
|
||||||
const mediaType = item.Type === 'Audio' ? 'music' : 'video';
|
|
||||||
|
|
||||||
// Starting a party from an episode queues the rest of the series from
|
|
||||||
// there on, so the host can hit "next" (see socketHandler.js) instead of
|
|
||||||
// everyone having to separately start a new party per episode. Doesn't
|
|
||||||
// apply to music - that party's queue is the live, participative one
|
|
||||||
// instead (see MusicQueueService.init below).
|
|
||||||
let queue = null;
|
|
||||||
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
|
||||||
const epsRes = await embyFetch(
|
|
||||||
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
|
|
||||||
req.embySession.embyToken
|
|
||||||
);
|
|
||||||
if (epsRes.ok) {
|
|
||||||
const epsData = await epsRes.json();
|
|
||||||
const all = epsData.Items || [];
|
|
||||||
const startIndex = all.findIndex((e) => e.Id === itemId);
|
|
||||||
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
|
|
||||||
queue = remaining.map((e) => ({
|
|
||||||
itemId: e.Id,
|
|
||||||
itemName: formatEpisodeName(e),
|
|
||||||
itemImageUrl: `/api/catalog/image/${e.Id}`
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const party = PartyManager.create(
|
|
||||||
req.embySession.userId,
|
|
||||||
req.embySession.userName,
|
|
||||||
itemId,
|
|
||||||
queue ? queue[0].itemName : item.Name,
|
|
||||||
`/api/catalog/image/${itemId}`,
|
|
||||||
queue,
|
|
||||||
mediaType
|
|
||||||
);
|
|
||||||
|
|
||||||
if (mediaType === 'music') {
|
|
||||||
MusicQueueService.init(party, {
|
|
||||||
itemId,
|
|
||||||
itemName: item.Name,
|
|
||||||
artistName: item.AlbumArtist || (item.Artists && item.Artists[0]) || null,
|
|
||||||
albumName: item.Album || null,
|
|
||||||
itemImageUrl: `/api/catalog/image/${itemId}`,
|
|
||||||
durationTicks: item.RunTimeTicks || null
|
|
||||||
}, req.embySession.userId, req.embySession.userName);
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcastLobby(io); // the new party shows up on everyone's home page immediately
|
|
||||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
||||||
});
|
|
||||||
|
|
||||||
function formatEpisodeName(episode) {
|
|
||||||
if (episode.ParentIndexNumber != null && episode.IndexNumber != null) {
|
|
||||||
const prefix = `S${episode.ParentIndexNumber}E${episode.IndexNumber}`;
|
|
||||||
return episode.SeriesName ? `${episode.SeriesName} - ${prefix} - ${episode.Name}` : `${prefix} - ${episode.Name}`;
|
|
||||||
}
|
|
||||||
return episode.Name;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adding a track to a music party's shared queue is REST, not a socket
|
|
||||||
// event (see socketHandler.js's top comment): the album-browsing page
|
|
||||||
// (client/js/views/album.js) that this is actually called from has no live
|
|
||||||
// connection to the party it's adding into - only the player view
|
|
||||||
// (musicPlayer.js) does. MusicQueueService's own "queueUpdated" broadcast
|
|
||||||
// (wired to the real io instance via attachIo, below) reaches anyone
|
|
||||||
// actually connected regardless of which page made this request.
|
|
||||||
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
|
||||||
const party = PartyManager.getById(req.params.partyId);
|
|
||||||
if (!party || party.mediaType !== 'music') {
|
|
||||||
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!party.members.has(req.embySession.userId)) {
|
|
||||||
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const track = req.body || {};
|
|
||||||
if (!track.itemId) {
|
|
||||||
res.status(400).json({ error: 'itemId is required.' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
MusicQueueService.addTrack(party, track, req.embySession.userId, req.embySession.userName);
|
|
||||||
broadcastLobby(io); // the queue/now-playing preview may have just changed
|
|
||||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
|
||||||
const party = PartyManager.getById(req.params.partyId);
|
|
||||||
if (!party) {
|
|
||||||
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
PartyManager.join(party, req.embySession.userId, req.embySession.userName);
|
|
||||||
broadcastLobby(io); // member count changed - reflect it on the home page immediately
|
|
||||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
|
||||||
const party = PartyManager.getById(req.params.partyId);
|
|
||||||
if (party) {
|
|
||||||
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
|
||||||
// handoff notification and the lobby refresh only live in one place.
|
|
||||||
applyLeave(io, party, req.embySession.userId);
|
|
||||||
}
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/parties/:partyId', requireSession, (req, res) => {
|
|
||||||
const party = PartyManager.getById(req.params.partyId);
|
|
||||||
if (!party) {
|
|
||||||
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
|
||||||
// Same-origin by default (no `cors` option needed) - the client connects
|
|
||||||
// with a bare io() call, consistent with everything else in this app never
|
|
||||||
// crossing origins. Socket.IO's own heartbeat (pingInterval/pingTimeout,
|
|
||||||
// defaults are fine here) is what detects dead connections - see
|
|
||||||
// socketHandler.js's top comment for why that matters.
|
|
||||||
const io = new SocketIoServer(server);
|
|
||||||
attachSocketServer(io, { parseCookies, getSession });
|
|
||||||
|
|
||||||
// Gives MusicQueueService a way to broadcast from its own internal
|
|
||||||
// auto-advance timer (no socket/request in scope there at all) and to
|
|
||||||
// refresh the home page's lobby card when the timer - not a person
|
|
||||||
// clicking anything - changes what's playing. Passed in rather than
|
|
||||||
// required directly from socketHandler.js/musicQueueService.js to avoid a
|
|
||||||
// circular require between the two.
|
|
||||||
MusicQueueService.attachIo(io, () => broadcastLobby(io));
|
|
||||||
|
|
||||||
setInterval(() => PartyManager.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
|
||||||
|
|
||||||
const port = process.env.PORT || 3000;
|
|
||||||
server.listen(port, () => {
|
|
||||||
console.log(`[watchparty] listening on :${port} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
|
||||||
});
|
|
||||||
344
server/src/index.ts
Normal file
344
server/src/index.ts
Normal file
|
|
@ -0,0 +1,344 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import path from 'path';
|
||||||
|
import http from 'http';
|
||||||
|
import express, { type NextFunction, type Request, type Response } from 'express';
|
||||||
|
import { Server as SocketIoServer } from 'socket.io';
|
||||||
|
|
||||||
|
import { EMBY_SERVER_URL, PORT } from './config';
|
||||||
|
import { AuthService, SESSION_COOKIE, SESSION_TTL_MS } from './services/AuthService';
|
||||||
|
import { CatalogService, CatalogFetchError } from './services/CatalogService';
|
||||||
|
import { PartyService } from './services/PartyService';
|
||||||
|
import { MusicQueueService } from './services/MusicQueueService';
|
||||||
|
import { SocketGateway } from './realtime/SocketGateway';
|
||||||
|
import type { ClientToServerEvents, MediaType, QueueItem, ServerToClientEvents } from './types';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', 1); // behind openresty/nginx - needed for req.secure to reflect the real scheme
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Everything the browser needs - the app shell AND every Emby call - is
|
||||||
|
// same-origin from here on. See docs/architecture.md for why: the previous
|
||||||
|
// version had the browser call Emby directly and hit real CORS problems.
|
||||||
|
// client/dist is esbuild's output (client/src compiled) - see
|
||||||
|
// client/build.mjs and docs/architecture.md's TypeScript-migration section.
|
||||||
|
app.use(express.static(path.join(__dirname, '..', '..', 'client', 'dist')));
|
||||||
|
|
||||||
|
function requireSession(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const session = AuthService.sessionFromRequest(req);
|
||||||
|
if (!session) {
|
||||||
|
res.status(401).json({ error: 'Session expirée, reconnecte-toi.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.embySession = session;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Express 4 doesn't catch a rejected Promise from an async route handler
|
||||||
|
* on its own - an unhandled rejection there would either crash the
|
||||||
|
* process or just hang the request forever, depending on Node version.
|
||||||
|
* Every route below is wrapped in this so a service throwing
|
||||||
|
* (CatalogFetchError, a raw network failure, anything) always turns into
|
||||||
|
* a real HTTP response instead - see the error-handling middleware at the
|
||||||
|
* bottom of this file for where it actually gets turned into one.
|
||||||
|
*/
|
||||||
|
function asyncRoute(handler: (req: Request, res: Response) => Promise<void>) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
handler(req, res).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a required `:name` route param. A matched Express route always has
|
||||||
|
* one (Express itself guarantees it; none of these routes use an optional
|
||||||
|
* `:name?` segment) - this just gives TypeScript the same certainty:
|
||||||
|
* `req.params` is an index-signature type, so `noUncheckedIndexedAccess`
|
||||||
|
* (tsconfig.json) sees every access as possibly-`undefined` on its own.
|
||||||
|
*/
|
||||||
|
function param(req: Request, name: string): string {
|
||||||
|
return req.params[name] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auth ----------------------------------------------------------------
|
||||||
|
// The browser only ever sends a username/password once, to us. From then on
|
||||||
|
// it holds an opaque session cookie - never Emby's own access token.
|
||||||
|
|
||||||
|
app.post('/api/auth/login', asyncRoute(async (req, res) => {
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
res.status(400).json({ error: "Nom d'utilisateur et mot de passe requis." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = await AuthService.login(username, password);
|
||||||
|
if (!sessionId) {
|
||||||
|
res.status(401).json({ error: 'Identifiants refusés.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.cookie(SESSION_COOKIE, sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: req.secure,
|
||||||
|
maxAge: SESSION_TTL_MS
|
||||||
|
});
|
||||||
|
res.json({ userName: AuthService.getSession(sessionId)!.userName });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.post('/api/auth/logout', (req, res) => {
|
||||||
|
const cookies = AuthService.parseCookies(req.headers.cookie);
|
||||||
|
if (cookies[SESSION_COOKIE]) {
|
||||||
|
AuthService.endSession(cookies[SESSION_COOKIE]);
|
||||||
|
}
|
||||||
|
res.clearCookie(SESSION_COOKIE);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', requireSession, (req, res) => {
|
||||||
|
res.json({ userName: req.embySession!.userName });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Catalog (proxied - the browser never talks to Emby directly) --------
|
||||||
|
// Thin wrappers over CatalogService - see its own file for the actual
|
||||||
|
// Emby calls/DTO shaping.
|
||||||
|
|
||||||
|
app.get('/api/catalog/search', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const items = await CatalogService.search(req.embySession!, String(req.query.q || ''));
|
||||||
|
res.json({ items });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/catalog/item/:itemId', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json(await CatalogService.getItem(req.embySession!, param(req, 'itemId')));
|
||||||
|
}));
|
||||||
|
|
||||||
|
// One entry per library, shown in the sidebar - see client/src's Shell.
|
||||||
|
app.get('/api/catalog/libraries', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json({ libraries: await CatalogService.getLibraries(req.embySession!) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Home page carousel: Emby's own "recently added" endpoint, one row per library.
|
||||||
|
app.get('/api/catalog/libraries/:libraryId/recent', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json({ items: await CatalogService.getRecentItems(req.embySession!, param(req, 'libraryId')) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Series -> Seasons -> Episodes, for the "Contenus vidéo en série" browsing page.
|
||||||
|
app.get('/api/catalog/series/:seriesId/seasons', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json({ seasons: await CatalogService.getSeriesSeasons(req.embySession!, param(req, 'seriesId')) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/catalog/series/:seriesId/episodes', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const seasonId = typeof req.query.seasonId === 'string' ? req.query.seasonId : undefined;
|
||||||
|
res.json({ episodes: await CatalogService.getSeriesEpisodes(req.embySession!, param(req, 'seriesId'), seasonId) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing pages.
|
||||||
|
app.get('/api/catalog/libraries/:libraryId/artists', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json({ artists: await CatalogService.getArtists(req.embySession!, param(req, 'libraryId')) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/catalog/artists/:artistId/albums', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json({ albums: await CatalogService.getArtistAlbums(req.embySession!, param(req, 'artistId')) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/catalog/albums/:albumId/tracks', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json({ tracks: await CatalogService.getAlbumTracks(req.embySession!, param(req, 'albumId')) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Audio/subtitle tracks available for an item, read from Emby's own playback
|
||||||
|
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
|
||||||
|
app.get('/api/catalog/item/:itemId/playback-options', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json(await CatalogService.getPlaybackOptions(req.embySession!, param(req, 'itemId')));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/catalog/libraries/:libraryId/items', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
res.json({ items: await CatalogService.getLibraryItems(req.embySession!, param(req, 'libraryId')) });
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Images/video/audio are requested by <img>/<video>/<audio> elements, which
|
||||||
|
// send the session cookie (same-origin) but can't carry a bearer header -
|
||||||
|
// so these three routes check the cookie directly instead of using the
|
||||||
|
// requireSession middleware's JSON error response, which a browser media
|
||||||
|
// element ignores.
|
||||||
|
|
||||||
|
app.get('/api/catalog/image/:itemId', asyncRoute(async (req, res) => {
|
||||||
|
const session = AuthService.sessionFromRequest(req);
|
||||||
|
if (!session) { res.status(401).end(); return; }
|
||||||
|
await CatalogService.proxyImage(req, res, session, param(req, 'itemId'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get('/api/catalog/stream/:itemId/master.m3u8', asyncRoute(async (req, res) => {
|
||||||
|
const session = AuthService.sessionFromRequest(req);
|
||||||
|
if (!session) { res.status(401).end(); return; }
|
||||||
|
await CatalogService.proxyStream(req, res, session, param(req, 'itemId'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Everything an HLS playlist references (variant playlists, segments)
|
||||||
|
// comes back through here - see EmbyClient.rewritePlaylist.
|
||||||
|
app.get('/api/catalog/stream/:itemId/raw', asyncRoute(async (req, res) => {
|
||||||
|
const session = AuthService.sessionFromRequest(req);
|
||||||
|
if (!session) { res.status(401).end(); return; }
|
||||||
|
await CatalogService.proxyRawStreamAsset(req, res, session, param(req, 'itemId'));
|
||||||
|
}));
|
||||||
|
|
||||||
|
// --- Parties ---------------------------------------------------------------
|
||||||
|
// No REST "list parties" endpoint: the home page's "watch parties en cours"
|
||||||
|
// list is live, over Socket.IO's "lobby" room (SocketGateway) - see
|
||||||
|
// docs/architecture.md. No join codes either - see PartyService's class
|
||||||
|
// comment for why a live list makes one redundant.
|
||||||
|
|
||||||
|
app.post('/api/parties', requireSession, asyncRoute(async (req, res) => {
|
||||||
|
const itemId = req.body?.itemId;
|
||||||
|
if (!itemId) {
|
||||||
|
res.status(400).json({ error: 'itemId is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = req.embySession!;
|
||||||
|
const item = await CatalogService.getRawItem(session, itemId);
|
||||||
|
const mediaType: MediaType = item.Type === 'Audio' ? 'music' : 'video';
|
||||||
|
|
||||||
|
// Starting a party from an episode queues the rest of the series from
|
||||||
|
// there on, so the host can hit "next" (see SocketGateway) instead of
|
||||||
|
// everyone having to separately start a new party per episode. Doesn't
|
||||||
|
// apply to music - that party's queue is the live, participative one
|
||||||
|
// instead (see MusicQueueService.init below).
|
||||||
|
let queue: QueueItem[] | null = null;
|
||||||
|
if (mediaType === 'video' && item.Type === 'Episode' && item.SeriesId) {
|
||||||
|
const all = await CatalogService.getAllSeriesEpisodesRaw(session, item.SeriesId);
|
||||||
|
const startIndex = all.findIndex((e) => e.Id === itemId);
|
||||||
|
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
|
||||||
|
queue = remaining.map((e) => ({
|
||||||
|
itemId: e.Id,
|
||||||
|
itemName: formatEpisodeName(e),
|
||||||
|
itemImageUrl: `/api/catalog/image/${e.Id}`
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const party = PartyService.create(
|
||||||
|
session.userId,
|
||||||
|
session.userName,
|
||||||
|
itemId,
|
||||||
|
queue ? queue[0]!.itemName : item.Name,
|
||||||
|
`/api/catalog/image/${itemId}`,
|
||||||
|
queue,
|
||||||
|
mediaType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mediaType === 'music') {
|
||||||
|
MusicQueueService.init(party, {
|
||||||
|
itemId,
|
||||||
|
itemName: item.Name,
|
||||||
|
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
||||||
|
albumName: item.Album || null,
|
||||||
|
itemImageUrl: `/api/catalog/image/${itemId}`,
|
||||||
|
durationTicks: item.RunTimeTicks || null
|
||||||
|
}, session.userId, session.userName);
|
||||||
|
}
|
||||||
|
|
||||||
|
SocketGateway.broadcastLobby(io); // the new party shows up on everyone's home page immediately
|
||||||
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
|
}));
|
||||||
|
|
||||||
|
function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?: number; SeriesName?: string; Name: string }): string {
|
||||||
|
if (episode.ParentIndexNumber != null && episode.IndexNumber != null) {
|
||||||
|
const prefix = `S${episode.ParentIndexNumber}E${episode.IndexNumber}`;
|
||||||
|
return episode.SeriesName ? `${episode.SeriesName} - ${prefix} - ${episode.Name}` : `${prefix} - ${episode.Name}`;
|
||||||
|
}
|
||||||
|
return episode.Name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding a track to a music party's shared queue is REST, not a socket
|
||||||
|
// event (see SocketGateway's top comment): the album-browsing page that
|
||||||
|
// this is actually called from has no live connection to the party it's
|
||||||
|
// adding into - only the player view does. MusicQueueService's own
|
||||||
|
// "queueUpdated" broadcast (wired to the real io instance via attachIo,
|
||||||
|
// below) reaches anyone actually connected regardless of which page made
|
||||||
|
// this request.
|
||||||
|
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
|
||||||
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
|
if (!party || party.mediaType !== 'music') {
|
||||||
|
res.status(404).json({ error: 'Watch party musique introuvable.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = req.embySession!;
|
||||||
|
if (!party.members.has(session.userId)) {
|
||||||
|
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const track = req.body || {};
|
||||||
|
if (!track.itemId) {
|
||||||
|
res.status(400).json({ error: 'itemId is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
MusicQueueService.addTrack(party, track, session.userId, session.userName);
|
||||||
|
SocketGateway.broadcastLobby(io); // the queue/now-playing preview may have just changed
|
||||||
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/parties/:partyId/join', requireSession, (req, res) => {
|
||||||
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
|
if (!party) {
|
||||||
|
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = req.embySession!;
|
||||||
|
PartyService.join(party, session.userId, session.userName);
|
||||||
|
SocketGateway.broadcastLobby(io); // member count changed - reflect it on the home page immediately
|
||||||
|
res.json(PartyService.toPublicState(party, session.userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/parties/:partyId/leave', requireSession, (req, res) => {
|
||||||
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
|
if (party) {
|
||||||
|
// Shared with the socket "leave"/"disconnect" handlers so the host-
|
||||||
|
// handoff notification and the lobby refresh only live in one place.
|
||||||
|
SocketGateway.applyLeave(io, party, req.embySession!.userId);
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/parties/:partyId', requireSession, (req, res) => {
|
||||||
|
const party = PartyService.getById(param(req, 'partyId'));
|
||||||
|
if (!party) {
|
||||||
|
res.status(404).json({ error: 'Party introuvable (elle est peut-être terminée).' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(PartyService.toPublicState(party, req.embySession!.userId));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Catches whatever asyncRoute() forwarded (or a synchronous throw) -
|
||||||
|
// CatalogFetchError carries its own status/message; anything else is an
|
||||||
|
// unexpected bug, logged in full server-side but never leaked to the
|
||||||
|
// client beyond a generic message.
|
||||||
|
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
if (err instanceof CatalogFetchError) {
|
||||||
|
res.status(err.status).json({ error: err.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error('[watchparty] unexpected error', err);
|
||||||
|
res.status(500).json({ error: 'Erreur serveur.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
// Same-origin by default (no `cors` option needed) - the client connects
|
||||||
|
// with a bare io() call, consistent with everything else in this app never
|
||||||
|
// crossing origins. Socket.IO's own heartbeat (pingInterval/pingTimeout,
|
||||||
|
// defaults are fine here) is what detects dead connections - see
|
||||||
|
// SocketGateway's top comment for why that matters.
|
||||||
|
const io = new SocketIoServer<ClientToServerEvents, ServerToClientEvents>(server);
|
||||||
|
SocketGateway.attach(io, (h) => AuthService.parseCookies(h), (id) => AuthService.getSession(id));
|
||||||
|
|
||||||
|
// Gives MusicQueueService a way to broadcast from its own internal
|
||||||
|
// auto-advance timer (no socket/request in scope there at all) and to
|
||||||
|
// refresh the home page's lobby card when the timer - not a person
|
||||||
|
// clicking anything - changes what's playing. Passed in rather than
|
||||||
|
// imported directly from SocketGateway/MusicQueueService to avoid a
|
||||||
|
// circular import between the two.
|
||||||
|
MusicQueueService.attachIo(io, () => SocketGateway.broadcastLobby(io));
|
||||||
|
|
||||||
|
setInterval(() => PartyService.removeIdleParties(4 * 60 * 60 * 1000), 10 * 60 * 1000);
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`[watchparty] listening on :${PORT} (Emby server: ${EMBY_SERVER_URL || 'NOT SET'})`);
|
||||||
|
});
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimal server-side logging for the party lifecycle - plain stdout lines,
|
|
||||||
* no logging library. This app runs as a single Docker container watched
|
|
||||||
* via `docker logs`/journalctl (see README's Deploying section), not behind
|
|
||||||
* a log aggregator, so a consistent prefix + timestamp is what actually
|
|
||||||
* makes these greppable in practice, not structured JSON nobody's parsing.
|
|
||||||
*
|
|
||||||
* Deliberately covers the party's business-level lifecycle only (created,
|
|
||||||
* joined, left, destroyed, host handoff, queue advance, playstate
|
|
||||||
* transitions, pause requests) - not connection-level noise like every
|
|
||||||
* Socket.IO reconnect or the 3s playstate heartbeat re-sending the same
|
|
||||||
* state (see socketHandler.js, which only calls this on an actual
|
|
||||||
* play/pause transition, not every heartbeat tick).
|
|
||||||
*/
|
|
||||||
function partyLog(message) {
|
|
||||||
console.log(`[watchparty:party] ${new Date().toISOString()} ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separate prefix/function for the streaming route (index.js's
|
|
||||||
// /master.m3u8 handler) - a different concern from the party lifecycle
|
|
||||||
// above (it's about one HTTP request, not party state), added specifically
|
|
||||||
// to make the language/subtitle/quality override actually debuggable from
|
|
||||||
// docker logs/journalctl without needing a browser devtools session on the
|
|
||||||
// machine running the server. Deliberately logs only the individual
|
|
||||||
// override values it cares about, never the full resolved URL or params
|
|
||||||
// blob - Emby's own TranscodingUrl can carry an api_key, and that has no
|
|
||||||
// business ending up in a log file.
|
|
||||||
function streamLog(message) {
|
|
||||||
console.log(`[watchparty:stream] ${new Date().toISOString()} ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `"name(id)"` - the same shape in every party lifecycle log line. */
|
|
||||||
function who(userId, userName) {
|
|
||||||
return `${userName}(${userId})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { partyLog, streamLog, who };
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { partyLog, who } = require('./logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owns the "participative playlist" behavior for a music party - kept out
|
|
||||||
* of partyManager.js because it's genuinely a different kind of shared
|
|
||||||
* state than the video model's single host-driven position (see
|
|
||||||
* docs/architecture.md's "Music" section): a live, mutable queue anyone
|
|
||||||
* can add to, server-authoritative timing (nobody is "the host" driving
|
|
||||||
* playback - a track just plays until its known duration runs out or a
|
|
||||||
* vote skips it), and skip-by-vote instead of a single controller.
|
|
||||||
*
|
|
||||||
* Chosen permission model (asked, not assumed - see session history):
|
|
||||||
* - Anyone can add a track to the queue, any time.
|
|
||||||
* - Anyone can pause/resume - it's reversible and low-stakes, unlike
|
|
||||||
* skipping to different content entirely.
|
|
||||||
* - Skipping the current track needs a strict majority vote of
|
|
||||||
* currently CONNECTED members ("membres présents"), not a simple click.
|
|
||||||
*
|
|
||||||
* One singleton instance (same shape as PartyManager below) - the state
|
|
||||||
* itself lives on each `party.music` object; this class is just the
|
|
||||||
* behavior around it, so it slots into the "one service class per
|
|
||||||
* feature" architecture the app is moving toward without a separate
|
|
||||||
* rewrite pass just for this file.
|
|
||||||
*/
|
|
||||||
class MusicQueueService {
|
|
||||||
constructor() {
|
|
||||||
this._io = null;
|
|
||||||
this._onTrackChanged = () => {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called once at server startup (index.js), after the Socket.IO server
|
|
||||||
* exists - nothing here can broadcast before this runs. `onTrackChanged`
|
|
||||||
* is how a party's home-page lobby card (host/preview image/name) gets
|
|
||||||
* refreshed when the *timer* advances the queue with nobody clicking
|
|
||||||
* anything - index.js wires it to broadcastLobby(io). Passed in rather
|
|
||||||
* than required directly from socketHandler.js to avoid a circular
|
|
||||||
* require (socketHandler.js already requires this module).
|
|
||||||
*/
|
|
||||||
attachIo(io, onTrackChanged) {
|
|
||||||
this._io = io;
|
|
||||||
this._onTrackChanged = onTrackChanged || (() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Called once, when a party is created from a music track (index.js). */
|
|
||||||
init(party, firstTrack, addedByUserId, addedByUserName) {
|
|
||||||
party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null };
|
|
||||||
if (firstTrack) {
|
|
||||||
this.addTrack(party, firstTrack, addedByUserId, addedByUserName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
addTrack(party, track, addedByUserId, addedByUserName) {
|
|
||||||
const entry = {
|
|
||||||
itemId: track.itemId,
|
|
||||||
itemName: track.itemName,
|
|
||||||
artistName: track.artistName || null,
|
|
||||||
albumName: track.albumName || null,
|
|
||||||
itemImageUrl: track.itemImageUrl,
|
|
||||||
// Emby ticks (100ns, same convention as video position) - converted
|
|
||||||
// once here so the rest of this class only ever deals in seconds.
|
|
||||||
durationSeconds: track.durationTicks ? Math.round(track.durationTicks / 10000000) : null,
|
|
||||||
addedByUserId,
|
|
||||||
addedByUserName
|
|
||||||
};
|
|
||||||
party.music.queue.push(entry);
|
|
||||||
party.lastActivityUtc = Date.now();
|
|
||||||
partyLog(`musicQueueAdd partyId=${party.id} user=${who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${party.music.queue.length}`);
|
|
||||||
if (!party.music.current) {
|
|
||||||
this._playNext(party); // queue was empty - this track starts immediately
|
|
||||||
}
|
|
||||||
this._broadcast(party);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** No host check - anyone can toggle this (see class comment). */
|
|
||||||
setPaused(party, isPaused) {
|
|
||||||
const current = party.music.current;
|
|
||||||
if (!current || current.isPaused === isPaused) return;
|
|
||||||
if (isPaused) {
|
|
||||||
current.isPaused = true;
|
|
||||||
current.pausedElapsedMs = Date.now() - current.startedUtc;
|
|
||||||
this._clearTimer(party);
|
|
||||||
} else {
|
|
||||||
current.isPaused = false;
|
|
||||||
current.startedUtc = Date.now() - current.pausedElapsedMs;
|
|
||||||
current.pausedElapsedMs = null;
|
|
||||||
this._scheduleAdvance(party);
|
|
||||||
}
|
|
||||||
party.lastActivityUtc = Date.now();
|
|
||||||
partyLog(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`);
|
|
||||||
this._broadcast(party);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers `userId`'s vote to skip the current track - a strict
|
|
||||||
* majority of `connectedMemberCount` (computed by the caller, which has
|
|
||||||
* the member list - see socketHandler.js) needed to actually skip, so a
|
|
||||||
* party of one skips on its own first vote. Broadcasts either way, so
|
|
||||||
* everyone sees the live tally even before it passes.
|
|
||||||
*/
|
|
||||||
voteSkip(party, userId, userName, connectedMemberCount) {
|
|
||||||
if (!party.music.current) return false;
|
|
||||||
party.music.skipVotes.add(userId);
|
|
||||||
const needed = Math.floor(connectedMemberCount / 2) + 1;
|
|
||||||
const votes = party.music.skipVotes.size;
|
|
||||||
partyLog(`musicVoteSkip partyId=${party.id} user=${who(userId, userName)} votes=${votes}/${connectedMemberCount} (needs ${needed})`);
|
|
||||||
const passed = votes >= needed;
|
|
||||||
if (passed) {
|
|
||||||
this._playNext(party);
|
|
||||||
}
|
|
||||||
this._broadcast(party);
|
|
||||||
return passed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a member leaves (socketHandler.js/partyManager.js) so a
|
|
||||||
* stale vote from someone no longer around can't help a future skip
|
|
||||||
* clear a bar computed against a smaller, more-accurate connected count.
|
|
||||||
*/
|
|
||||||
removeVote(party, userId) {
|
|
||||||
if (party.music) party.music.skipVotes.delete(userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Live position, in seconds - computed on demand from wall-clock time,
|
|
||||||
* never ticked/stored, so there's nothing to drift or leak. */
|
|
||||||
getElapsedSeconds(party) {
|
|
||||||
const current = party.music.current;
|
|
||||||
if (!current) return 0;
|
|
||||||
const elapsedMs = current.isPaused ? current.pausedElapsedMs : (Date.now() - current.startedUtc);
|
|
||||||
return Math.max(0, Math.floor(elapsedMs / 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Public snapshot for toPublicState()/the "queueUpdated" broadcast. */
|
|
||||||
getState(party) {
|
|
||||||
const current = party.music.current;
|
|
||||||
return {
|
|
||||||
queue: party.music.queue.map(publicTrack),
|
|
||||||
current: current ? { ...publicTrack(current.track), isPaused: current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) } : null,
|
|
||||||
skipVotes: party.music.skipVotes.size
|
|
||||||
// Deliberately no "votes needed" here - the client already gets the
|
|
||||||
// member list (with each one's connected flag) from the existing
|
|
||||||
// "members" broadcast and can compute the same
|
|
||||||
// Math.floor(connected/2)+1 this class uses, without this payload
|
|
||||||
// needing to duplicate that count on every single queue change.
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called from PartyManager.leave()'s destroy path. This is the actual
|
|
||||||
* leak fix: an uncleared advanceTimer would fire against a party that no
|
|
||||||
* longer exists in PartyManager's map, forever holding a reference to
|
|
||||||
* it (and everything it closes over) - the exact class of bug the
|
|
||||||
* Socket.IO/router-cleanup work earlier in this project was about
|
|
||||||
* catching (see docs/architecture.md).
|
|
||||||
*/
|
|
||||||
destroy(party) {
|
|
||||||
if (party.music) this._clearTimer(party);
|
|
||||||
}
|
|
||||||
|
|
||||||
_playNext(party) {
|
|
||||||
this._clearTimer(party);
|
|
||||||
party.music.skipVotes.clear();
|
|
||||||
const next = party.music.queue.shift();
|
|
||||||
if (!next) {
|
|
||||||
party.music.current = null;
|
|
||||||
partyLog(`musicQueueEmpty partyId=${party.id}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
party.music.current = { track: next, startedUtc: Date.now(), isPaused: false, pausedElapsedMs: null };
|
|
||||||
// Keeps the lobby/home-page party card's preview (name + image)
|
|
||||||
// current even though this is a queue-driven change, not a host
|
|
||||||
// action - see PartyManager.toPublicState, which reads these same
|
|
||||||
// top-level fields for both video and music parties alike.
|
|
||||||
party.itemName = next.itemName;
|
|
||||||
party.itemImageUrl = next.itemImageUrl;
|
|
||||||
partyLog(`musicNowPlaying partyId=${party.id} track="${next.itemName}"`);
|
|
||||||
this._scheduleAdvance(party);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-authoritative auto-advance: schedules the move to the next
|
|
||||||
* track for exactly when this one's known duration runs out, rather
|
|
||||||
* than waiting for any client to report "it ended" (which, with no
|
|
||||||
* single host for music, could race across several clients' players all
|
|
||||||
* finishing within milliseconds of each other and all trying to
|
|
||||||
* advance). A track with no known duration just relies on a manual
|
|
||||||
* vote-skip instead - nothing scheduled, nothing to leak.
|
|
||||||
*/
|
|
||||||
_scheduleAdvance(party) {
|
|
||||||
const current = party.music.current;
|
|
||||||
if (!current || !current.track.durationSeconds) return;
|
|
||||||
const elapsedMs = Date.now() - current.startedUtc;
|
|
||||||
const remainingMs = current.track.durationSeconds * 1000 - elapsedMs;
|
|
||||||
party.music.advanceTimer = setTimeout(() => {
|
|
||||||
this._playNext(party);
|
|
||||||
this._broadcast(party);
|
|
||||||
this._onTrackChanged(party);
|
|
||||||
}, Math.max(0, remainingMs));
|
|
||||||
}
|
|
||||||
|
|
||||||
_clearTimer(party) {
|
|
||||||
if (party.music && party.music.advanceTimer) {
|
|
||||||
clearTimeout(party.music.advanceTimer);
|
|
||||||
party.music.advanceTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_broadcast(party) {
|
|
||||||
if (this._io) this._io.to(party.id).emit('queueUpdated', this.getState(party));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function publicTrack(t) {
|
|
||||||
return {
|
|
||||||
itemId: t.itemId,
|
|
||||||
itemName: t.itemName,
|
|
||||||
artistName: t.artistName,
|
|
||||||
albumName: t.albumName,
|
|
||||||
itemImageUrl: t.itemImageUrl,
|
|
||||||
durationSeconds: t.durationSeconds,
|
|
||||||
addedByUserId: t.addedByUserId,
|
|
||||||
addedByUserName: t.addedByUserName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { MusicQueueService: new MusicQueueService() };
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { partyLog, who } = require('./logger');
|
|
||||||
const { MusicQueueService } = require('./musicQueueService');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory party store - mirrors the Party/PartyMember/PartyManager design
|
|
||||||
* from the earlier Emby-plugin attempt (see git history / docs/architecture.md),
|
|
||||||
* adapted for a model where we own the player: one host drives playback,
|
|
||||||
* everyone else's client just applies whatever the host reports. Nothing is
|
|
||||||
* persisted to disk; a server restart clears every party, which is fine - a
|
|
||||||
* party is meant to live for one watch session, not persist for weeks.
|
|
||||||
*
|
|
||||||
* No join codes: the home page's live "watch parties en cours" list (see
|
|
||||||
* socketHandler.js's lobby broadcast) is the only way to find a party, and
|
|
||||||
* since that's real-time, a code that has to be copied/typed out of band
|
|
||||||
* doesn't add anything - see docs/architecture.md.
|
|
||||||
*/
|
|
||||||
class PartyManager {
|
|
||||||
constructor() {
|
|
||||||
this.partiesById = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `queue` is the ordered list of { itemId, itemName, itemImageUrl } this
|
|
||||||
* party will play through - for a movie/standalone episode it's just the
|
|
||||||
* one item; for "start watching this series from here", index.js builds
|
|
||||||
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
|
|
||||||
* always mirror queue[queueIndex] - kept as top-level fields too since
|
|
||||||
* that's what most of the UI reads. Unused for `mediaType: 'music'`
|
|
||||||
* parties (still populated with a one-item fallback so those same
|
|
||||||
* top-level fields have *something* sane in them for the lobby/home-page
|
|
||||||
* card) - a music party's real, mutable queue is `party.music`, owned by
|
|
||||||
* musicQueueService.js, not this fixed pre-built one.
|
|
||||||
*/
|
|
||||||
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue, mediaType) {
|
|
||||||
const party = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
mediaType: mediaType || 'video',
|
|
||||||
itemId,
|
|
||||||
itemName,
|
|
||||||
itemImageUrl,
|
|
||||||
queue: queue && queue.length ? queue : [{ itemId, itemName, itemImageUrl }],
|
|
||||||
queueIndex: 0,
|
|
||||||
hostUserId,
|
|
||||||
createdUtc: Date.now(),
|
|
||||||
lastActivityUtc: Date.now(),
|
|
||||||
// userId -> { userId, userName, connection, isPaused, positionTicks, lastUpdatedUtc }
|
|
||||||
members: new Map()
|
|
||||||
};
|
|
||||||
|
|
||||||
party.members.set(hostUserId, this._newMember(hostUserId, hostUserName));
|
|
||||||
this.partiesById.set(party.id, party);
|
|
||||||
partyLog(`created partyId=${party.id} mediaType=${party.mediaType} host=${who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`);
|
|
||||||
return party;
|
|
||||||
}
|
|
||||||
|
|
||||||
getById(partyId) {
|
|
||||||
return this.partiesById.get(partyId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
join(party, userId, userName) {
|
|
||||||
const member = this._newMember(userId, userName);
|
|
||||||
party.members.set(userId, member);
|
|
||||||
party.lastActivityUtc = Date.now();
|
|
||||||
partyLog(`joined partyId=${party.id} user=${who(userId, userName)} memberCount=${party.members.size}`);
|
|
||||||
return member;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a member. If they were the host, promotes whoever's left
|
|
||||||
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
|
|
||||||
* left, the party is destroyed outright. Returns what happened so callers
|
|
||||||
* (socketHandler.js's applyLeave) can notify the new host and refresh the
|
|
||||||
* home page's live list - see docs/architecture.md.
|
|
||||||
*/
|
|
||||||
leave(party, userId) {
|
|
||||||
const leavingMember = party.members.get(userId);
|
|
||||||
const wasHost = party.hostUserId === userId;
|
|
||||||
party.members.delete(userId);
|
|
||||||
party.lastActivityUtc = Date.now();
|
|
||||||
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
|
|
||||||
|
|
||||||
let newHostUserId = party.hostUserId;
|
|
||||||
if (wasHost) {
|
|
||||||
const next = party.members.keys().next();
|
|
||||||
newHostUserId = next.done ? null : next.value;
|
|
||||||
party.hostUserId = newHostUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const destroyed = party.members.size === 0;
|
|
||||||
const whoLeft = leavingMember ? who(userId, leavingMember.userName) : userId;
|
|
||||||
if (destroyed) {
|
|
||||||
this.partiesById.delete(party.id);
|
|
||||||
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
|
||||||
partyLog(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
|
||||||
} else if (wasHost && newHostUserId != null) {
|
|
||||||
const newHost = party.members.get(newHostUserId);
|
|
||||||
partyLog(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size} hostHandoff->${who(newHostUserId, newHost.userName)}`);
|
|
||||||
} else {
|
|
||||||
partyLog(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { hostChanged: wasHost && newHostUserId != null, newHostUserId, destroyed };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves to the next item in the queue (e.g. next episode). Returns false
|
|
||||||
* if there's nothing after the current position. Resets every member's
|
|
||||||
* known position/pause state, since it's a new item playing from zero.
|
|
||||||
*/
|
|
||||||
advanceQueue(party) {
|
|
||||||
if (party.queueIndex >= party.queue.length - 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
party.queueIndex += 1;
|
|
||||||
const next = party.queue[party.queueIndex];
|
|
||||||
party.itemId = next.itemId;
|
|
||||||
party.itemName = next.itemName;
|
|
||||||
party.itemImageUrl = next.itemImageUrl;
|
|
||||||
for (const member of party.members.values()) {
|
|
||||||
member.positionTicks = 0;
|
|
||||||
member.isPaused = false;
|
|
||||||
member.lastUpdatedUtc = Date.now();
|
|
||||||
}
|
|
||||||
party.lastActivityUtc = Date.now();
|
|
||||||
partyLog(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All active parties - powers the "watch parties en cours" home section. */
|
|
||||||
listActive() {
|
|
||||||
return Array.from(this.partiesById.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
removeIdleParties(idleTimeoutMs) {
|
|
||||||
const cutoff = Date.now() - idleTimeoutMs;
|
|
||||||
for (const party of this.partiesById.values()) {
|
|
||||||
if (party.lastActivityUtc < cutoff) {
|
|
||||||
this.partiesById.delete(party.id);
|
|
||||||
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
|
||||||
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
|
||||||
partyLog(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `viewerUserId` lets the response say "youAreHost" instead of the client
|
|
||||||
* having to know its own Emby user id to figure that out itself - now that
|
|
||||||
* auth is a server-side session (see embyAuth.js), the client doesn't hold
|
|
||||||
* an id to compare with at all.
|
|
||||||
*/
|
|
||||||
toPublicState(party, viewerUserId) {
|
|
||||||
const host = party.members.get(party.hostUserId);
|
|
||||||
return {
|
|
||||||
partyId: party.id,
|
|
||||||
mediaType: party.mediaType,
|
|
||||||
itemId: party.itemId,
|
|
||||||
itemName: party.itemName,
|
|
||||||
itemImageUrl: party.itemImageUrl,
|
|
||||||
createdUtc: party.createdUtc,
|
|
||||||
// hasNext/queueLength/queueIndex describe the fixed video episode
|
|
||||||
// queue - meaningless for a music party (see `music` below instead).
|
|
||||||
hasNext: party.queueIndex < party.queue.length - 1,
|
|
||||||
queueLength: party.queue.length,
|
|
||||||
queueIndex: party.queueIndex,
|
|
||||||
hostUserName: host ? host.userName : null,
|
|
||||||
memberCount: party.members.size,
|
|
||||||
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
|
|
||||||
members: Array.from(party.members.values()).map((m) => ({
|
|
||||||
userId: m.userId,
|
|
||||||
userName: m.userName,
|
|
||||||
isHost: m.userId === party.hostUserId,
|
|
||||||
isPaused: m.isPaused,
|
|
||||||
positionTicks: m.positionTicks,
|
|
||||||
connected: !!m.connection
|
|
||||||
})),
|
|
||||||
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
_newMember(userId, userName) {
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
userName,
|
|
||||||
connection: null,
|
|
||||||
isPaused: true,
|
|
||||||
positionTicks: 0,
|
|
||||||
lastUpdatedUtc: Date.now()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { PartyManager: new PartyManager() };
|
|
||||||
229
server/src/realtime/SocketGateway.ts
Normal file
229
server/src/realtime/SocketGateway.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
import { MusicQueueService } from '../services/MusicQueueService';
|
||||||
|
import { PartyService } from '../services/PartyService';
|
||||||
|
import type { AppServer, LeaveResult, LobbyPartyDto, Party, SocketMemberDto } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Realtime side of the app: one party's own room, plus a shared "lobby"
|
||||||
|
* room that powers the home page's live "watch parties en cours" list.
|
||||||
|
*
|
||||||
|
* Auth comes from the session cookie sent during the initial HTTP
|
||||||
|
* handshake - exposed here via socket.request.headers.cookie.
|
||||||
|
*
|
||||||
|
* Events (client -> server unless noted) - see types.ts's
|
||||||
|
* ClientToServerEvents/ServerToClientEvents for the exact payload shapes:
|
||||||
|
* "subscribeLobby" joins the "lobby" room
|
||||||
|
* "parties" (server -> client) full snapshot, sent on subscribe
|
||||||
|
* and again whenever any party changes
|
||||||
|
* "join" {partyId} joins that party's own room
|
||||||
|
* "members" (server -> client) that party's member list
|
||||||
|
*
|
||||||
|
* -- mediaType: 'video' parties --
|
||||||
|
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
||||||
|
* "playstate" (server -> client, to everyone in the party but the sender)
|
||||||
|
* "next" (host only - advance the queue)
|
||||||
|
* "itemChanged" (server -> client)
|
||||||
|
*
|
||||||
|
* -- mediaType: 'music' parties (see MusicQueueService for the model) --
|
||||||
|
* "voteSkip" (anyone - see MusicQueueService for the threshold)
|
||||||
|
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
|
||||||
|
* "queueUpdated" (server -> client) - see MusicQueueService.getState() for the shape
|
||||||
|
* (adding a track is REST, not a socket event - POST
|
||||||
|
* /api/parties/:partyId/tracks in index.ts - since it needs to work
|
||||||
|
* from the album-browsing page too, which has no live connection to
|
||||||
|
* the party it might be adding into; MusicQueueService's own
|
||||||
|
* "queueUpdated" broadcast fires regardless of which path called it)
|
||||||
|
*
|
||||||
|
* -- both --
|
||||||
|
* "hostChanged" (server -> client, to the new host only, no payload)
|
||||||
|
* "requestPause" (throttled per member - see below;
|
||||||
|
* video only - a music party has no
|
||||||
|
* host to ask, everyone already has
|
||||||
|
* direct pause/vote-skip access)
|
||||||
|
* "pauseRequested" (server -> client, everyone in the party)
|
||||||
|
* "leave"
|
||||||
|
* "errorMessage" (server -> client)
|
||||||
|
* ("error" is a reserved Socket.IO event name - using our own to avoid
|
||||||
|
* colliding with its internal error handling.)
|
||||||
|
*/
|
||||||
|
class SocketGatewayClass {
|
||||||
|
attach(io: AppServer, parseCookies: (header: string | undefined) => Record<string, string>, getSession: (id: string) => { userId: string; userName: string } | null): void {
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
const cookies = parseCookies(socket.request.headers.cookie);
|
||||||
|
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
socket.emit('errorMessage', { message: 'Session expirée - reconnecte-toi.' });
|
||||||
|
socket.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.userId;
|
||||||
|
let party: Party | null = null;
|
||||||
|
|
||||||
|
socket.on('subscribeLobby', () => {
|
||||||
|
socket.join('lobby');
|
||||||
|
socket.emit('parties', this.lobbySnapshot());
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('join', (msg) => {
|
||||||
|
const p = PartyService.getById(msg?.partyId);
|
||||||
|
const member = p?.members.get(userId);
|
||||||
|
if (!p || !member) {
|
||||||
|
socket.emit('errorMessage', { message: "Tu n'es pas membre de cette party." });
|
||||||
|
socket.disconnect(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
party = p;
|
||||||
|
member.connection = socket;
|
||||||
|
socket.join(party.id);
|
||||||
|
this.broadcastMembers(io, party);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playstate', (msg) => {
|
||||||
|
if (!party || userId !== party.hostUserId) {
|
||||||
|
return; // only the host drives playback - see docs/architecture.md
|
||||||
|
}
|
||||||
|
const member = party.members.get(userId)!;
|
||||||
|
const wasPaused = member.isPaused;
|
||||||
|
member.isPaused = !!msg?.isPaused;
|
||||||
|
member.positionTicks = Number(msg?.positionTicks) || 0;
|
||||||
|
member.lastUpdatedUtc = Date.now();
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
|
||||||
|
// Logged only on an actual play/pause transition, not every one of
|
||||||
|
// these - the host's player also re-sends the current state on a
|
||||||
|
// plain 3s heartbeat (see player), which would otherwise spam this
|
||||||
|
// line for every active party every 3 seconds regardless of
|
||||||
|
// whether anything actually changed.
|
||||||
|
if (member.isPaused !== wasPaused) {
|
||||||
|
const positionSeconds = Math.round(member.positionTicks / 10000000);
|
||||||
|
Logger.party(`playstate partyId=${party.id} host=${Logger.who(userId, member.userName)} -> ${member.isPaused ? 'paused' : 'playing'} at ${positionSeconds}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('next', () => {
|
||||||
|
if (!party || userId !== party.hostUserId) {
|
||||||
|
return; // only the host can advance the queue
|
||||||
|
}
|
||||||
|
if (PartyService.advanceQueue(party)) {
|
||||||
|
io.to(party.id).emit('itemChanged', {
|
||||||
|
itemId: party.itemId,
|
||||||
|
itemName: party.itemName,
|
||||||
|
itemImageUrl: party.itemImageUrl,
|
||||||
|
queueIndex: party.queueIndex,
|
||||||
|
queueLength: party.queue.length,
|
||||||
|
hasNext: party.queueIndex < party.queue.length - 1
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Majority of currently CONNECTED members, not everyone who's ever
|
||||||
|
// joined - see MusicQueueService.
|
||||||
|
socket.on('voteSkip', () => {
|
||||||
|
const member = party?.mediaType === 'music' ? party.members.get(userId) : undefined;
|
||||||
|
if (!party || !member) return;
|
||||||
|
const connectedCount = Array.from(party.members.values()).filter((m) => m.connection).length;
|
||||||
|
MusicQueueService.voteSkip(party, userId, member.userName, connectedCount);
|
||||||
|
this.broadcastLobby(io);
|
||||||
|
});
|
||||||
|
|
||||||
|
// No host check, unlike video's "playstate" - see MusicQueueService.
|
||||||
|
socket.on('musicPlaystate', (msg) => {
|
||||||
|
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
|
||||||
|
MusicQueueService.setPaused(party, !!msg?.isPaused);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anyone (not just followers - harmless either way) can ask the host
|
||||||
|
// to pause; the presence overlay (the video player) is what triggers
|
||||||
|
// this. Broadcast to the whole party, not just the host, so everyone
|
||||||
|
// sees why playback is about to pause. Throttled per member
|
||||||
|
// server-side (not just the client's own cooldown, which a stray
|
||||||
|
// extra click - or anyone poking the socket directly - would
|
||||||
|
// bypass). Video only: a music party has no host to ask - everyone
|
||||||
|
// already has direct pause access via "musicPlaystate".
|
||||||
|
socket.on('requestPause', () => {
|
||||||
|
if (!party || party.mediaType === 'music') return;
|
||||||
|
const member = party.members.get(userId);
|
||||||
|
if (!member) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (member.lastPauseRequestUtc && now - member.lastPauseRequestUtc < 5000) return;
|
||||||
|
member.lastPauseRequestUtc = now;
|
||||||
|
Logger.party(`pauseRequested partyId=${party.id} user=${Logger.who(userId, member.userName)}`);
|
||||||
|
io.to(party.id).emit('pauseRequested', { userId, userName: member.userName });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('leave', () => {
|
||||||
|
if (!party) return;
|
||||||
|
this.applyLeave(io, party, userId);
|
||||||
|
party = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fires on a clean "leave", a dead-connection timeout (Socket.IO's
|
||||||
|
// own heartbeat - see docs/architecture.md), or any other way the
|
||||||
|
// socket goes away - the one cleanup path that always runs, unlike
|
||||||
|
// relying on the client to always get a chance to emit "leave" first.
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
if (party) this.applyLeave(io, party, userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes `userId` from `party` and handles everything that follows from
|
||||||
|
* it: if they were the host, the new host (if any) gets a personal
|
||||||
|
* "hostChanged" so their player can promote itself without a page
|
||||||
|
* reload; either way, the party's own room and the lobby both get
|
||||||
|
* refreshed. Shared by the socket "leave"/"disconnect" handlers above
|
||||||
|
* and index.ts's REST leave endpoint so this logic - especially the
|
||||||
|
* host handoff - only has to be right in one place.
|
||||||
|
*/
|
||||||
|
applyLeave(io: AppServer, party: Party, userId: string): LeaveResult {
|
||||||
|
const result = PartyService.leave(party, userId);
|
||||||
|
if (!result.destroyed) {
|
||||||
|
this.broadcastMembers(io, party);
|
||||||
|
if (result.hostChanged && result.newHostUserId) {
|
||||||
|
const newHost = party.members.get(result.newHostUserId);
|
||||||
|
newHost?.connection?.emit('hostChanged');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.broadcastLobby(io);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastLobby(io: AppServer): void {
|
||||||
|
io.to('lobby').emit('parties', this.lobbySnapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastMembers(io: AppServer, party: Party): void {
|
||||||
|
const members: SocketMemberDto[] = Array.from(party.members.values()).map((m) => ({
|
||||||
|
userId: m.userId,
|
||||||
|
userName: m.userName,
|
||||||
|
isHost: m.userId === party.hostUserId,
|
||||||
|
connected: !!m.connection
|
||||||
|
}));
|
||||||
|
io.to(party.id).emit('members', members);
|
||||||
|
}
|
||||||
|
|
||||||
|
private lobbySnapshot(): LobbyPartyDto[] {
|
||||||
|
return PartyService.listActive().map((party) => {
|
||||||
|
const host = party.members.get(party.hostUserId);
|
||||||
|
return {
|
||||||
|
partyId: party.id,
|
||||||
|
mediaType: party.mediaType,
|
||||||
|
itemId: party.itemId,
|
||||||
|
itemName: party.itemName,
|
||||||
|
itemImageUrl: party.itemImageUrl,
|
||||||
|
hostUserName: host ? host.userName : null,
|
||||||
|
memberCount: party.members.size,
|
||||||
|
createdUtc: party.createdUtc // client computes/ticks elapsed time itself from this
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SocketGateway = new SocketGatewayClass();
|
||||||
101
server/src/services/AuthService.ts
Normal file
101
server/src/services/AuthService.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { EMBY_SERVER_URL } from '../config';
|
||||||
|
import type { EmbySession } from '../types';
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = 'wp_session';
|
||||||
|
export const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||||
|
|
||||||
|
function authHeaderValue(): string {
|
||||||
|
return 'MediaBrowser Client="WatchParty", Device="Server", DeviceId="watchparty-server", Version="0.1.0"';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmbyAuthResponse {
|
||||||
|
AccessToken: string;
|
||||||
|
ServerId: string;
|
||||||
|
User: { Id: string; Name: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-side Emby login + in-memory session store. The browser only ever
|
||||||
|
* sends its username/password once, to us; we forward that to Emby, and
|
||||||
|
* from then on the browser only holds an opaque session id (cookie) -
|
||||||
|
* never Emby's own access token. This is what makes "the server proxies
|
||||||
|
* everything" possible: the browser has nothing it could use to talk to
|
||||||
|
* Emby even if it tried. See docs/architecture.md.
|
||||||
|
*
|
||||||
|
* Singleton, same shape as PartyService/MusicQueueService - sessions are
|
||||||
|
* in-memory, like party state, so a restart signs everyone out, which is
|
||||||
|
* fine (an Emby login is one click away).
|
||||||
|
*/
|
||||||
|
class AuthServiceClass {
|
||||||
|
private sessions = new Map<string, EmbySession>();
|
||||||
|
|
||||||
|
/** Returns a new session id, or null on bad credentials / unreachable Emby. */
|
||||||
|
async login(username: string, password: string): Promise<string | null> {
|
||||||
|
if (!EMBY_SERVER_URL) return null;
|
||||||
|
|
||||||
|
let res: globalThis.Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${EMBY_SERVER_URL}/Users/AuthenticateByName`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Emby-Authorization': authHeaderValue()
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ Username: username, Pw: password })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[watchparty] Emby login request failed:', (err as Error).message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const data = (await res.json()) as EmbyAuthResponse;
|
||||||
|
const sessionId = crypto.randomUUID();
|
||||||
|
this.sessions.set(sessionId, {
|
||||||
|
embyToken: data.AccessToken,
|
||||||
|
userId: data.User.Id,
|
||||||
|
userName: data.User.Name,
|
||||||
|
serverId: data.ServerId, // needed to build "open in Emby" deep links
|
||||||
|
createdUtc: Date.now()
|
||||||
|
});
|
||||||
|
return sessionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(sessionId: string): EmbySession | null {
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (!session) return null;
|
||||||
|
if (Date.now() - session.createdUtc > SESSION_TTL_MS) {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
endSession(sessionId: string): void {
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCookies(cookieHeader: string | undefined): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
(cookieHeader || '').split(';').forEach((part) => {
|
||||||
|
const idx = part.indexOf('=');
|
||||||
|
if (idx === -1) return;
|
||||||
|
out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim());
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads the caller's session from an Express request's cookie, or null. */
|
||||||
|
sessionFromRequest(req: Request): EmbySession | null {
|
||||||
|
const cookies = this.parseCookies(req.headers.cookie);
|
||||||
|
const id = cookies[SESSION_COOKIE];
|
||||||
|
return id ? this.getSession(id) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AuthService = new AuthServiceClass();
|
||||||
385
server/src/services/CatalogService.ts
Normal file
385
server/src/services/CatalogService.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { EMBY_SERVER_URL } from '../config';
|
||||||
|
import { EmbyClient } from '../emby/EmbyClient';
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
import type { EmbyItem, EmbySession, ItemDto, LibraryDto, PlaybackOptionsDto, SeasonDto } from '../types';
|
||||||
|
|
||||||
|
// 'MusicVideo' is Emby's own item type for a musicvideos library's
|
||||||
|
// contents (distinct from the generic 'Video', which is home videos).
|
||||||
|
// 'Audio' is a music track. Series/Season/MusicArtist/MusicAlbum aren't
|
||||||
|
// directly playable - only show "start a party" for things Emby can
|
||||||
|
// actually stream. "Voir sur Emby" always works though.
|
||||||
|
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video', 'MusicVideo', 'Audio']);
|
||||||
|
|
||||||
|
// Emby's /Views can return all sorts of things beyond actual media
|
||||||
|
// libraries - boxset collections, playlists, live TV, photos, books, a
|
||||||
|
// mixed-content folder with no CollectionType at all - none of which this
|
||||||
|
// app has any UI for. Only these four are ever shown, in the sidebar or
|
||||||
|
// anywhere else: everything else is dropped here, at the source, so every
|
||||||
|
// consumer (shell/sidebar, home's carousels, direct navigation to a
|
||||||
|
// library route) is filtered the same way without each having to repeat
|
||||||
|
// the same check.
|
||||||
|
const VISIBLE_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'music', 'musicvideos']);
|
||||||
|
|
||||||
|
interface EmbyItemsResponse {
|
||||||
|
Items?: EmbyItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaybackInfoResponse {
|
||||||
|
MediaSources?: Array<{
|
||||||
|
Id?: string;
|
||||||
|
TranscodingUrl?: string;
|
||||||
|
MediaStreams?: Array<{ Type: string; Index: number; DisplayTitle?: string; Language?: string }>;
|
||||||
|
}>;
|
||||||
|
PlaySessionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything catalog-shaped: search, item detail, libraries, recently
|
||||||
|
* added, Series -> Seasons -> Episodes, Library -> Artists -> Albums ->
|
||||||
|
* Tracks, playback negotiation, and the actual image/HLS/audio proxying.
|
||||||
|
* The single largest extraction from the old index.js - previously ~15
|
||||||
|
* route handlers' worth of logic inlined directly in the Express app.
|
||||||
|
*
|
||||||
|
* Stateless (no singleton instance state, unlike PartyService/
|
||||||
|
* MusicQueueService/AuthService) - kept as a class anyway for consistency
|
||||||
|
* with the rest of server/src/services, and because it's still very much
|
||||||
|
* a "feature" in the sense this app's architecture groups by (see
|
||||||
|
* docs/architecture.md).
|
||||||
|
*/
|
||||||
|
class CatalogServiceClass {
|
||||||
|
async search(session: EmbySession, query: string): Promise<ItemDto[]> {
|
||||||
|
const q = query.trim();
|
||||||
|
if (q.length < 2) return [];
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
searchTerm: q,
|
||||||
|
IncludeItemTypes: 'Movie,Episode,Series',
|
||||||
|
Recursive: 'true',
|
||||||
|
Fields: 'Overview,ProductionYear',
|
||||||
|
ImageTypeLimit: '1',
|
||||||
|
Limit: '24'
|
||||||
|
});
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Recherche impossible côté Emby.');
|
||||||
|
return (data.Items || []).map((i) => this.toItemDto(i, session.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw Emby item (Type, SeriesId, RunTimeTicks, ...) - used where a
|
||||||
|
* caller needs more than the DTO exposes, e.g. deciding a new party's
|
||||||
|
* mediaType from the clicked item's real Emby Type (see index.ts).
|
||||||
|
* Unlike every other method here, this preserves Emby's own status
|
||||||
|
* (typically a real 404) rather than a blanket 502 - "titre introuvable"
|
||||||
|
* is a not-found, not an upstream failure. */
|
||||||
|
async getRawItem(session: EmbySession, itemId: string): Promise<EmbyItem> {
|
||||||
|
return this.fetchJson<EmbyItem>(`/Users/${session.userId}/Items/${itemId}`, session, 'Titre introuvable.', { preserveEmbyStatus: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItem(session: EmbySession, itemId: string): Promise<ItemDto> {
|
||||||
|
return this.toItemDto(await this.getRawItem(session, itemId), session.serverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLibraries(session: EmbySession): Promise<LibraryDto[]> {
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Views`, session, 'Impossible de récupérer les bibliothèques.');
|
||||||
|
return (data.Items || [])
|
||||||
|
.filter((v) => VISIBLE_COLLECTION_TYPES.has((v as unknown as { CollectionType?: string }).CollectionType || ''))
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.Id,
|
||||||
|
name: v.Name,
|
||||||
|
// "movies" / "tvshows" / "music" / "musicvideos" - drives which
|
||||||
|
// browsing experience client/src/views/library renders.
|
||||||
|
collectionType: (v as unknown as { CollectionType?: string }).CollectionType || ''
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Home page carousel: Emby's own "recently added" endpoint, one row per library. */
|
||||||
|
async getRecentItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
|
||||||
|
const params = new URLSearchParams({ ParentId: libraryId, Limit: '16', Fields: 'ProductionYear' });
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse | EmbyItem[]>(`/Users/${session.userId}/Items/Latest?${params}`, session, 'Impossible de récupérer les ajouts récents.');
|
||||||
|
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
|
||||||
|
const items = Array.isArray(data) ? data : (data.Items || []);
|
||||||
|
return items.map((i) => this.toItemDto(i, session.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSeriesSeasons(session: EmbySession, seriesId: string): Promise<SeasonDto[]> {
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Shows/${seriesId}/Seasons?userId=${session.userId}`, session, 'Impossible de récupérer les saisons.');
|
||||||
|
return (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSeriesEpisodes(session: EmbySession, seriesId: string, seasonId?: string): Promise<ItemDto[]> {
|
||||||
|
const params = new URLSearchParams({ userId: session.userId, Fields: 'Overview' });
|
||||||
|
if (seasonId) params.set('SeasonId', seasonId);
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Shows/${seriesId}/Episodes?${params}`, session, 'Impossible de récupérer les épisodes.');
|
||||||
|
return (data.Items || []).map((e) => this.toItemDto(e, session.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw episodes (no season filter) - used by index.ts to build a new
|
||||||
|
* party's fixed episode queue when starting from a specific episode. */
|
||||||
|
async getAllSeriesEpisodesRaw(session: EmbySession, seriesId: string): Promise<EmbyItem[]> {
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Shows/${seriesId}/Episodes?userId=${session.userId}&Fields=Overview`, session, 'Impossible de récupérer les épisodes.');
|
||||||
|
return data.Items || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Library -> Artists -> Albums -> Tracks, for the "Musique" browsing
|
||||||
|
// pages. Each its own Emby endpoint/query rather than guessing folder
|
||||||
|
// structure, same reasoning as Shows -> Seasons -> Episodes above.
|
||||||
|
|
||||||
|
async getArtists(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
|
||||||
|
const params = new URLSearchParams({ ParentId: libraryId, UserId: session.userId, SortBy: 'SortName' });
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Artists?${params}`, session, 'Impossible de récupérer les artistes.');
|
||||||
|
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArtistAlbums(session: EmbySession, artistId: string): Promise<ItemDto[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ArtistIds: artistId,
|
||||||
|
UserId: session.userId,
|
||||||
|
IncludeItemTypes: 'MusicAlbum',
|
||||||
|
Recursive: 'true',
|
||||||
|
SortBy: 'ProductionYear,SortName',
|
||||||
|
Fields: 'ProductionYear'
|
||||||
|
});
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les albums.');
|
||||||
|
return (data.Items || []).map((a) => this.toItemDto(a, session.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlbumTracks(session: EmbySession, albumId: string): Promise<ItemDto[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ParentId: albumId,
|
||||||
|
UserId: session.userId,
|
||||||
|
IncludeItemTypes: 'Audio',
|
||||||
|
SortBy: 'IndexNumber',
|
||||||
|
Fields: 'RunTimeTicks,IndexNumber,Album,AlbumArtist'
|
||||||
|
});
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer les morceaux.');
|
||||||
|
return (data.Items || []).map((t) => this.toItemDto(t, session.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLibraryItems(session: EmbySession, libraryId: string): Promise<ItemDto[]> {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
ParentId: libraryId,
|
||||||
|
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
|
||||||
|
SortBy: 'SortName',
|
||||||
|
Fields: 'Overview,ProductionYear',
|
||||||
|
ImageTypeLimit: '1',
|
||||||
|
Limit: '300'
|
||||||
|
});
|
||||||
|
const data = await this.fetchJson<EmbyItemsResponse>(`/Users/${session.userId}/Items?${params}`, session, 'Impossible de récupérer le contenu de cette bibliothèque.');
|
||||||
|
return (data.Items || []).map((i) => this.toItemDto(i, session.serverId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Audio/subtitle tracks available for an item, read from Emby's own
|
||||||
|
* playback negotiation (PlaybackInfo) - powers the player's
|
||||||
|
* language/subtitle pickers. */
|
||||||
|
async getPlaybackOptions(session: EmbySession, itemId: string): Promise<PlaybackOptionsDto> {
|
||||||
|
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session, 'Impossible de lire les options de lecture.');
|
||||||
|
const source = (info.MediaSources || [])[0];
|
||||||
|
const streams = source?.MediaStreams || [];
|
||||||
|
const label = (s: { Index: number; DisplayTitle?: string; Language?: string }) => s.DisplayTitle || s.Language || `Piste ${s.Index}`;
|
||||||
|
return {
|
||||||
|
audioTracks: streams.filter((s) => s.Type === 'Audio').map((s) => ({ index: s.Index, label: label(s) })),
|
||||||
|
subtitleTracks: streams.filter((s) => s.Type === 'Subtitle').map((s) => ({ index: s.Index, label: label(s) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Direct proxying (images/video/audio) - these need the raw
|
||||||
|
// Express req/res, unlike everything above which just returns JSON. ---
|
||||||
|
|
||||||
|
async proxyImage(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
|
||||||
|
await EmbyClient.proxyResponse(req, res, `/Items/${itemId}/Images/Primary?maxWidth=400`, session.embyToken, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask Emby how it wants this played rather than hand-building a
|
||||||
|
* /Videos/{id}/master.m3u8 URL ourselves: the first version did that
|
||||||
|
* with only VideoCodec/AudioCodec, and Emby's server threw "Value
|
||||||
|
* cannot be null (Parameter 'key')" - almost certainly an internal
|
||||||
|
* dictionary lookup (MediaSourceId/PlaySessionId/DeviceId) that URL
|
||||||
|
* never supplied. PlaybackInfo returns a MediaSource with its own
|
||||||
|
* ready-to-use TranscodingUrl carrying whatever Emby actually needs.
|
||||||
|
*
|
||||||
|
* Music tracks stream through this exact method too (same PlaybackInfo
|
||||||
|
* -> TranscodingUrl dance - Emby's negotiation isn't video-specific) -
|
||||||
|
* the only place that actually differs is the hand-built fallback path,
|
||||||
|
* which is genuinely different between the two (/Videos/ vs /Audio/)
|
||||||
|
* and has no way to know which without being told: PlaybackInfo's own
|
||||||
|
* response doesn't reliably carry the item's Type back, and looking it
|
||||||
|
* up would be an extra Emby round trip for what the client already
|
||||||
|
* knows - so the client passes `mediaType=audio` (see audioStreamUrl()
|
||||||
|
* in client/src/api/embyApi.ts).
|
||||||
|
*/
|
||||||
|
async proxyStream(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
|
||||||
|
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate (and
|
||||||
|
// SubtitleMethod=Encode, forced by applyPlaybackOverrides whenever a
|
||||||
|
// subtitle is requested - see its own comment below) come from the
|
||||||
|
// player's language/subtitle/quality pickers, applied to the
|
||||||
|
// negotiation itself, not just the final URL. That distinction
|
||||||
|
// matters: whether to actually re-encode (required to burn in a
|
||||||
|
// subtitle) is a decision Emby makes *during* PlaybackInfo, not
|
||||||
|
// something a later query-string tweak on the resulting URL can
|
||||||
|
// retroactively change if Emby already set up a plain remux. Still
|
||||||
|
// not trusted to have taken effect on its own, though (this app never
|
||||||
|
// sends the POST-with-DeviceProfile body real Emby web clients use
|
||||||
|
// for that negotiation) - applyPlaybackOverrides runs a second time
|
||||||
|
// below, on whatever URL results, so a track pick can't silently
|
||||||
|
// no-op regardless of which path Emby's response takes.
|
||||||
|
const query = req.query as Record<string, string | undefined>;
|
||||||
|
const infoParams = new URLSearchParams({ UserId: session.userId });
|
||||||
|
this.applyPlaybackOverrides(infoParams, query);
|
||||||
|
const info = await this.fetchJson<PlaybackInfoResponse>(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session, 'Impossible de préparer la lecture de ce titre.');
|
||||||
|
|
||||||
|
const source = (info.MediaSources || [])[0];
|
||||||
|
if (!source) {
|
||||||
|
throw new CatalogFetchError('Aucune source de lecture disponible pour ce titre.', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(verify): TranscodingUrl not confirmed present for every source
|
||||||
|
// type/Emby version (e.g. a source Emby considers direct-playable may
|
||||||
|
// not have one at all) - the hand-built fallback below now at least
|
||||||
|
// supplies the parameters PlaybackInfo reveals (MediaSourceId,
|
||||||
|
// PlaySessionId, UserId) instead of guessing, but is still a
|
||||||
|
// fallback, not the primary path.
|
||||||
|
const isAudio = query.mediaType === 'audio';
|
||||||
|
let target: string;
|
||||||
|
if (source.TranscodingUrl) {
|
||||||
|
const [urlPath, urlQuery] = source.TranscodingUrl.split('?');
|
||||||
|
const params = new URLSearchParams(urlQuery || '');
|
||||||
|
this.applyPlaybackOverrides(params, query);
|
||||||
|
target = `${urlPath}?${params}`;
|
||||||
|
this.logStreamRequest(itemId, 'negotiated', params);
|
||||||
|
} else {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
UserId: session.userId,
|
||||||
|
MediaSourceId: source.Id || '',
|
||||||
|
PlaySessionId: info.PlaySessionId || '',
|
||||||
|
AudioCodec: 'aac',
|
||||||
|
...(isAudio ? {} : { VideoCodec: 'h264' })
|
||||||
|
});
|
||||||
|
this.applyPlaybackOverrides(params, query);
|
||||||
|
target = `/${isAudio ? 'Audio' : 'Videos'}/${itemId}/master.m3u8?${params}`;
|
||||||
|
this.logStreamRequest(itemId, 'fallback', params);
|
||||||
|
}
|
||||||
|
|
||||||
|
await EmbyClient.proxyResponse(req, res, target, session.embyToken, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything an HLS playlist references (variant playlists, segments)
|
||||||
|
* comes back through here - see EmbyClient.rewritePlaylist. */
|
||||||
|
async proxyRawStreamAsset(req: Request, res: Response, session: EmbySession, itemId: string): Promise<void> {
|
||||||
|
const target = (req.query.u as string) || '';
|
||||||
|
let pathAndQuery: string;
|
||||||
|
if (/^https?:\/\//i.test(target)) {
|
||||||
|
if (!target.startsWith(EMBY_SERVER_URL)) {
|
||||||
|
res.status(400).end(); // never proxy an arbitrary URL, only our own Emby server
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pathAndQuery = target;
|
||||||
|
} else {
|
||||||
|
pathAndQuery = `/Videos/${itemId}/${target}`;
|
||||||
|
}
|
||||||
|
await EmbyClient.proxyResponse(req, res, pathAndQuery, session.embyToken, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forces this app's own audio/subtitle/quality selection onto a
|
||||||
|
* transcode URL, whether that URL came from Emby's own PlaybackInfo
|
||||||
|
* negotiation or was hand-built as a fallback. Without this, a pick
|
||||||
|
* from the player's settings menu could silently do nothing if Emby's
|
||||||
|
* negotiation didn't apply the query-string override the same way its
|
||||||
|
* own web client's POST-with-DeviceProfile request would have.
|
||||||
|
*
|
||||||
|
* SubtitleMethod=Encode is forced whenever a subtitle track is
|
||||||
|
* requested: left to its own default, Emby may instead deliver text
|
||||||
|
* subtitles as a separate selectable HLS rendition (SubtitleMethod=Hls)
|
||||||
|
* rather than burning them into the video - and this player never wires
|
||||||
|
* up hls.js's subtitleTrack API to pick one, so a rendition-based
|
||||||
|
* subtitle would be present in the manifest but never actually shown.
|
||||||
|
* Encode guarantees the subtitle is part of the one video stream this
|
||||||
|
* player already renders. Picking "Aucun" removes any subtitle Emby's
|
||||||
|
* own negotiation added, rather than just not adding a new one -
|
||||||
|
* otherwise turning subtitles off from a state where Emby had already
|
||||||
|
* picked a default would be a no-op too.
|
||||||
|
*/
|
||||||
|
private applyPlaybackOverrides(params: URLSearchParams, query: Record<string, string | undefined>): void {
|
||||||
|
if (query.AudioStreamIndex) params.set('AudioStreamIndex', query.AudioStreamIndex);
|
||||||
|
if (query.SubtitleStreamIndex) {
|
||||||
|
params.set('SubtitleStreamIndex', query.SubtitleStreamIndex);
|
||||||
|
params.set('SubtitleMethod', 'Encode');
|
||||||
|
} else {
|
||||||
|
params.delete('SubtitleStreamIndex');
|
||||||
|
params.delete('SubtitleMethod');
|
||||||
|
}
|
||||||
|
if (query.MaxStreamingBitrate) params.set('MaxStreamingBitrate', query.MaxStreamingBitrate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deliberately logs only these specific override values, never the full
|
||||||
|
// resolved URL/params blob - Emby's own TranscodingUrl can carry an
|
||||||
|
// api_key, and that has no business ending up in a log file. Makes it
|
||||||
|
// possible to confirm from docker logs/journalctl whether a requested
|
||||||
|
// override made it into the request Emby received, without a devtools
|
||||||
|
// session on whatever machine is running the server.
|
||||||
|
private logStreamRequest(itemId: string, source: 'negotiated' | 'fallback', params: URLSearchParams): void {
|
||||||
|
Logger.stream(
|
||||||
|
`master.m3u8 itemId=${itemId} source=${source} ` +
|
||||||
|
`audio=${params.get('AudioStreamIndex') || '(default)'} ` +
|
||||||
|
`subtitle=${params.get('SubtitleStreamIndex') || '(none)'} ` +
|
||||||
|
`subtitleMethod=${params.get('SubtitleMethod') || '(none)'} ` +
|
||||||
|
`quality=${params.get('MaxStreamingBitrate') || '(auto)'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toItemDto(item: EmbyItem, serverId: string): ItemDto {
|
||||||
|
return {
|
||||||
|
id: item.Id,
|
||||||
|
name: item.Name,
|
||||||
|
overview: item.Overview,
|
||||||
|
year: item.ProductionYear,
|
||||||
|
type: item.Type,
|
||||||
|
canWatchParty: WATCH_PARTY_TYPES.has(item.Type || ''),
|
||||||
|
imageUrl: `/api/catalog/image/${item.Id}`,
|
||||||
|
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`,
|
||||||
|
// Track-specific (null for anything else - harmless, every consumer
|
||||||
|
// that cares about these only reads them for an Audio item).
|
||||||
|
durationTicks: item.RunTimeTicks || null,
|
||||||
|
indexNumber: item.IndexNumber != null ? item.IndexNumber : null,
|
||||||
|
artistName: item.AlbumArtist || item.Artists?.[0] || null,
|
||||||
|
albumName: item.Album || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GETs an Emby endpoint as this session's user and parses the JSON body,
|
||||||
|
* or throws a CatalogFetchError - never touches an Express response
|
||||||
|
* directly. Every route in index.ts runs through asyncRoute(), which
|
||||||
|
* catches this (and anything else) and turns it into the right HTTP
|
||||||
|
* response in one place, rather than each of the ~15 routes below
|
||||||
|
* needing its own try/catch around every await.
|
||||||
|
*/
|
||||||
|
private async fetchJson<T>(
|
||||||
|
pathAndQuery: string,
|
||||||
|
session: EmbySession,
|
||||||
|
errorMessage: string,
|
||||||
|
options: { preserveEmbyStatus?: boolean } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const embyRes = await EmbyClient.fetch(pathAndQuery, session.embyToken);
|
||||||
|
if (!embyRes.ok) {
|
||||||
|
throw new CatalogFetchError(errorMessage, options.preserveEmbyStatus ? embyRes.status : 502);
|
||||||
|
}
|
||||||
|
return (await embyRes.json()) as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A known, user-facing catalog failure (Emby unreachable, item not found,
|
||||||
|
* ...) - index.ts's asyncRoute() catches this specifically and responds
|
||||||
|
* with `status`/`message` verbatim; anything else that throws is an
|
||||||
|
* unexpected bug and gets a generic 500 instead (see index.ts's error
|
||||||
|
* -handling middleware).
|
||||||
|
*/
|
||||||
|
export class CatalogFetchError extends Error {
|
||||||
|
constructor(message: string, public status: number) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CatalogService = new CatalogServiceClass();
|
||||||
219
server/src/services/MusicQueueService.ts
Normal file
219
server/src/services/MusicQueueService.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
import type { AppServer, MusicCurrentPublic, MusicPublicState, MusicTrack, MusicTrackInput, Party } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the "participative playlist" behavior for a music party - kept out
|
||||||
|
* of PartyService because it's genuinely a different kind of shared state
|
||||||
|
* than the video model's single host-driven position (see
|
||||||
|
* docs/architecture.md's "Music" section): a live, mutable queue anyone
|
||||||
|
* can add to, server-authoritative timing (nobody is "the host" driving
|
||||||
|
* playback - a track just plays until its known duration runs out or a
|
||||||
|
* vote skips it), and skip-by-vote instead of a single controller.
|
||||||
|
*
|
||||||
|
* Chosen permission model (asked, not assumed - see session history):
|
||||||
|
* - Anyone can add a track to the queue, any time.
|
||||||
|
* - Anyone can pause/resume - it's reversible and low-stakes, unlike
|
||||||
|
* skipping to different content entirely.
|
||||||
|
* - Skipping the current track needs a strict majority vote of
|
||||||
|
* currently CONNECTED members ("membres présents"), not a simple click.
|
||||||
|
*
|
||||||
|
* Singleton (exported as an instance below) - the state itself lives on
|
||||||
|
* each `party.music` object; this class is just the behavior around it.
|
||||||
|
*/
|
||||||
|
class MusicQueueServiceClass {
|
||||||
|
private io: AppServer | null = null;
|
||||||
|
private onTrackChanged: (party: Party) => void = () => {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called once at server startup (index.ts), after the Socket.IO server
|
||||||
|
* exists - nothing here can broadcast before this runs. `onTrackChanged`
|
||||||
|
* is how a party's home-page lobby card (host/preview image/name) gets
|
||||||
|
* refreshed when the *timer* advances the queue with nobody clicking
|
||||||
|
* anything - index.ts wires it to broadcastLobby(io). Passed in rather
|
||||||
|
* than imported directly from SocketGateway to avoid a circular import
|
||||||
|
* (SocketGateway already imports this module).
|
||||||
|
*/
|
||||||
|
attachIo(io: AppServer, onTrackChanged?: (party: Party) => void): void {
|
||||||
|
this.io = io;
|
||||||
|
this.onTrackChanged = onTrackChanged || (() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called once, when a party is created from a music track (index.ts). */
|
||||||
|
init(party: Party, firstTrack: MusicTrackInput | null, addedByUserId: string, addedByUserName: string): void {
|
||||||
|
party.music = { queue: [], current: null, skipVotes: new Set(), advanceTimer: null };
|
||||||
|
if (firstTrack) {
|
||||||
|
this.addTrack(party, firstTrack, addedByUserId, addedByUserName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addTrack(party: Party, track: MusicTrackInput, addedByUserId: string, addedByUserName: string): void {
|
||||||
|
const music = party.music!;
|
||||||
|
const entry: MusicTrack = {
|
||||||
|
itemId: track.itemId,
|
||||||
|
itemName: track.itemName,
|
||||||
|
artistName: track.artistName || null,
|
||||||
|
albumName: track.albumName || null,
|
||||||
|
itemImageUrl: track.itemImageUrl,
|
||||||
|
// Emby ticks (100ns, same convention as video position) - converted
|
||||||
|
// once here so the rest of this class only ever deals in seconds.
|
||||||
|
durationSeconds: track.durationTicks ? Math.round(track.durationTicks / 10000000) : null,
|
||||||
|
addedByUserId,
|
||||||
|
addedByUserName
|
||||||
|
};
|
||||||
|
music.queue.push(entry);
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
Logger.party(`musicQueueAdd partyId=${party.id} user=${Logger.who(addedByUserId, addedByUserName)} track="${entry.itemName}" queueLength=${music.queue.length}`);
|
||||||
|
if (!music.current) {
|
||||||
|
this.playNext(party); // queue was empty - this track starts immediately
|
||||||
|
}
|
||||||
|
this.broadcast(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No host check - anyone can toggle this (see class comment). */
|
||||||
|
setPaused(party: Party, isPaused: boolean): void {
|
||||||
|
const current = party.music?.current;
|
||||||
|
if (!current || current.isPaused === isPaused) return;
|
||||||
|
if (isPaused) {
|
||||||
|
current.isPaused = true;
|
||||||
|
current.pausedElapsedMs = Date.now() - current.startedUtc;
|
||||||
|
this.clearTimer(party);
|
||||||
|
} else {
|
||||||
|
current.isPaused = false;
|
||||||
|
current.startedUtc = Date.now() - (current.pausedElapsedMs ?? 0);
|
||||||
|
current.pausedElapsedMs = null;
|
||||||
|
this.scheduleAdvance(party);
|
||||||
|
}
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
Logger.party(`musicPlaystate partyId=${party.id} -> ${isPaused ? 'paused' : 'playing'}`);
|
||||||
|
this.broadcast(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers `userId`'s vote to skip the current track - a strict
|
||||||
|
* majority of `connectedMemberCount` (computed by the caller, which has
|
||||||
|
* the member list - see SocketGateway) needed to actually skip, so a
|
||||||
|
* party of one skips on its own first vote. Broadcasts either way, so
|
||||||
|
* everyone sees the live tally even before it passes.
|
||||||
|
*/
|
||||||
|
voteSkip(party: Party, userId: string, userName: string, connectedMemberCount: number): boolean {
|
||||||
|
const music = party.music;
|
||||||
|
if (!music || !music.current) return false;
|
||||||
|
music.skipVotes.add(userId);
|
||||||
|
const needed = Math.floor(connectedMemberCount / 2) + 1;
|
||||||
|
const votes = music.skipVotes.size;
|
||||||
|
Logger.party(`musicVoteSkip partyId=${party.id} user=${Logger.who(userId, userName)} votes=${votes}/${connectedMemberCount} (needs ${needed})`);
|
||||||
|
const passed = votes >= needed;
|
||||||
|
if (passed) {
|
||||||
|
this.playNext(party);
|
||||||
|
}
|
||||||
|
this.broadcast(party);
|
||||||
|
return passed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a member leaves (SocketGateway/PartyService) so a stale
|
||||||
|
* vote from someone no longer around can't help a future skip clear a
|
||||||
|
* bar computed against a smaller, more-accurate connected count.
|
||||||
|
*/
|
||||||
|
removeVote(party: Party, userId: string): void {
|
||||||
|
party.music?.skipVotes.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live position, in seconds - computed on demand from wall-clock time,
|
||||||
|
* never ticked/stored, so there's nothing to drift or leak. */
|
||||||
|
getElapsedSeconds(party: Party): number {
|
||||||
|
const current = party.music?.current;
|
||||||
|
if (!current) return 0;
|
||||||
|
const elapsedMs = current.isPaused ? (current.pausedElapsedMs ?? 0) : (Date.now() - current.startedUtc);
|
||||||
|
return Math.max(0, Math.floor(elapsedMs / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public snapshot for PartyService.toPublicState()/the "queueUpdated" broadcast. */
|
||||||
|
getState(party: Party): MusicPublicState {
|
||||||
|
const music = party.music;
|
||||||
|
if (!music) return { queue: [], current: null, skipVotes: 0 };
|
||||||
|
const current: MusicCurrentPublic | null = music.current
|
||||||
|
? { ...music.current.track, isPaused: music.current.isPaused, elapsedSeconds: this.getElapsedSeconds(party) }
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
queue: music.queue,
|
||||||
|
current,
|
||||||
|
skipVotes: music.skipVotes.size
|
||||||
|
// Deliberately no "votes needed" here - the client already gets the
|
||||||
|
// member list (with each one's connected flag) from the existing
|
||||||
|
// "members" broadcast and can compute the same
|
||||||
|
// Math.floor(connected/2)+1 this class uses, without this payload
|
||||||
|
// needing to duplicate that count on every single queue change.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called from PartyService.leave()'s destroy path. This is the actual
|
||||||
|
* leak fix: an uncleared advanceTimer would fire against a party that no
|
||||||
|
* longer exists in PartyService's map, forever holding a reference to
|
||||||
|
* it (and everything it closes over) - the exact class of bug the
|
||||||
|
* Socket.IO/router-cleanup work earlier in this project was about
|
||||||
|
* catching (see docs/architecture.md).
|
||||||
|
*/
|
||||||
|
destroy(party: Party): void {
|
||||||
|
if (party.music) this.clearTimer(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
private playNext(party: Party): void {
|
||||||
|
const music = party.music!;
|
||||||
|
this.clearTimer(party);
|
||||||
|
music.skipVotes.clear();
|
||||||
|
const next = music.queue.shift();
|
||||||
|
if (!next) {
|
||||||
|
music.current = null;
|
||||||
|
Logger.party(`musicQueueEmpty partyId=${party.id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
music.current = { track: next, startedUtc: Date.now(), isPaused: false, pausedElapsedMs: null };
|
||||||
|
// Keeps the lobby/home-page party card's preview (name + image)
|
||||||
|
// current even though this is a queue-driven change, not a host
|
||||||
|
// action - see PartyService.toPublicState, which reads these same
|
||||||
|
// top-level fields for both video and music parties alike.
|
||||||
|
party.itemName = next.itemName;
|
||||||
|
party.itemImageUrl = next.itemImageUrl;
|
||||||
|
Logger.party(`musicNowPlaying partyId=${party.id} track="${next.itemName}"`);
|
||||||
|
this.scheduleAdvance(party);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server-authoritative auto-advance: schedules the move to the next
|
||||||
|
* track for exactly when this one's known duration runs out, rather
|
||||||
|
* than waiting for any client to report "it ended" (which, with no
|
||||||
|
* single host for music, could race across several clients' players all
|
||||||
|
* finishing within milliseconds of each other and all trying to
|
||||||
|
* advance). A track with no known duration just relies on a manual
|
||||||
|
* vote-skip instead - nothing scheduled, nothing to leak.
|
||||||
|
*/
|
||||||
|
private scheduleAdvance(party: Party): void {
|
||||||
|
const music = party.music!;
|
||||||
|
const current = music.current;
|
||||||
|
if (!current || !current.track.durationSeconds) return;
|
||||||
|
const elapsedMs = Date.now() - current.startedUtc;
|
||||||
|
const remainingMs = current.track.durationSeconds * 1000 - elapsedMs;
|
||||||
|
music.advanceTimer = setTimeout(() => {
|
||||||
|
this.playNext(party);
|
||||||
|
this.broadcast(party);
|
||||||
|
this.onTrackChanged(party);
|
||||||
|
}, Math.max(0, remainingMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimer(party: Party): void {
|
||||||
|
if (party.music?.advanceTimer) {
|
||||||
|
clearTimeout(party.music.advanceTimer);
|
||||||
|
party.music.advanceTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcast(party: Party): void {
|
||||||
|
this.io?.to(party.id).emit('queueUpdated', this.getState(party));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MusicQueueService = new MusicQueueServiceClass();
|
||||||
211
server/src/services/PartyService.ts
Normal file
211
server/src/services/PartyService.ts
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { Logger } from '../Logger';
|
||||||
|
import { MusicQueueService } from './MusicQueueService';
|
||||||
|
import type { LeaveResult, MediaType, Party, PartyMember, PartyPublicState, QueueItem } from '../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory party store - mirrors the Party/PartyMember design from the
|
||||||
|
* earlier Emby-plugin attempt (see git history / docs/architecture.md),
|
||||||
|
* adapted for a model where we own the player: for a video party, one host
|
||||||
|
* drives playback and everyone else's client just applies whatever the
|
||||||
|
* host reports (a music party has no host for playback at all - see
|
||||||
|
* MusicQueueService). Nothing is persisted to disk; a server restart
|
||||||
|
* clears every party, which is fine - a party is meant to live for one
|
||||||
|
* watch/listen session, not persist for weeks.
|
||||||
|
*
|
||||||
|
* No join codes: the home page's live "watch parties en cours" list (see
|
||||||
|
* SocketGateway's lobby broadcast) is the only way to find a party, and
|
||||||
|
* since that's real-time, a code that has to be copied/typed out of band
|
||||||
|
* doesn't add anything - see docs/architecture.md.
|
||||||
|
*
|
||||||
|
* Singleton (exported as an instance below), same shape as
|
||||||
|
* MusicQueueService/AuthService.
|
||||||
|
*/
|
||||||
|
class PartyServiceClass {
|
||||||
|
private partiesById = new Map<string, Party>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `queue` is the ordered list of {itemId, itemName, itemImageUrl} this
|
||||||
|
* party will play through - for a movie/standalone episode it's just the
|
||||||
|
* one item; for "start watching this series from here", CatalogService
|
||||||
|
* builds the rest of the season/series into it. `itemId`/`itemName`/
|
||||||
|
* `itemImageUrl` always mirror queue[queueIndex] - kept as top-level
|
||||||
|
* fields too since that's what most of the UI reads. Unused for
|
||||||
|
* `mediaType: 'music'` parties (still populated with a one-item fallback
|
||||||
|
* so those same top-level fields have *something* sane in them for the
|
||||||
|
* lobby/home-page card) - a music party's real, mutable queue is
|
||||||
|
* `party.music`, owned by MusicQueueService, not this fixed pre-built one.
|
||||||
|
*/
|
||||||
|
create(
|
||||||
|
hostUserId: string,
|
||||||
|
hostUserName: string,
|
||||||
|
itemId: string,
|
||||||
|
itemName: string,
|
||||||
|
itemImageUrl: string,
|
||||||
|
queue: QueueItem[] | null,
|
||||||
|
mediaType: MediaType
|
||||||
|
): Party {
|
||||||
|
const party: Party = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
mediaType: mediaType || 'video',
|
||||||
|
itemId,
|
||||||
|
itemName,
|
||||||
|
itemImageUrl,
|
||||||
|
queue: queue && queue.length ? queue : [{ itemId, itemName, itemImageUrl }],
|
||||||
|
queueIndex: 0,
|
||||||
|
hostUserId,
|
||||||
|
createdUtc: Date.now(),
|
||||||
|
lastActivityUtc: Date.now(),
|
||||||
|
members: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
party.members.set(hostUserId, this.newMember(hostUserId, hostUserName));
|
||||||
|
this.partiesById.set(party.id, party);
|
||||||
|
Logger.party(`created partyId=${party.id} mediaType=${party.mediaType} host=${Logger.who(hostUserId, hostUserName)} item="${itemName}" queueLength=${party.queue.length}`);
|
||||||
|
return party;
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(partyId: string): Party | null {
|
||||||
|
return this.partiesById.get(partyId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
join(party: Party, userId: string, userName: string): PartyMember {
|
||||||
|
const member = this.newMember(userId, userName);
|
||||||
|
party.members.set(userId, member);
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
Logger.party(`joined partyId=${party.id} user=${Logger.who(userId, userName)} memberCount=${party.members.size}`);
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a member. If they were the host, promotes whoever's left
|
||||||
|
* (arbitrary but deterministic-ish - good enough for v1); if nobody's
|
||||||
|
* left, the party is destroyed outright. Returns what happened so
|
||||||
|
* callers (SocketGateway's applyLeave) can notify the new host and
|
||||||
|
* refresh the home page's live list - see docs/architecture.md.
|
||||||
|
*/
|
||||||
|
leave(party: Party, userId: string): LeaveResult {
|
||||||
|
const leavingMember = party.members.get(userId);
|
||||||
|
const wasHost = party.hostUserId === userId;
|
||||||
|
party.members.delete(userId);
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
MusicQueueService.removeVote(party, userId); // no-op for a video party (party.music is unset)
|
||||||
|
|
||||||
|
let newHostUserId: string | null = party.hostUserId;
|
||||||
|
if (wasHost) {
|
||||||
|
const next = party.members.keys().next();
|
||||||
|
newHostUserId = next.done ? null : next.value;
|
||||||
|
// Only reassign when there's a real successor - if nobody's left,
|
||||||
|
// the party is destroyed immediately below and this stale value is
|
||||||
|
// never read again, so there's no reason to let it go null.
|
||||||
|
if (newHostUserId) party.hostUserId = newHostUserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destroyed = party.members.size === 0;
|
||||||
|
const whoLeft = leavingMember ? Logger.who(userId, leavingMember.userName) : userId;
|
||||||
|
if (destroyed) {
|
||||||
|
this.partiesById.delete(party.id);
|
||||||
|
MusicQueueService.destroy(party); // clears the auto-advance timer, if any - see its own comment
|
||||||
|
Logger.party(`destroyed partyId=${party.id} (last member ${whoLeft} left)`);
|
||||||
|
} else if (wasHost && newHostUserId != null) {
|
||||||
|
const newHost = party.members.get(newHostUserId)!;
|
||||||
|
Logger.party(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size} hostHandoff->${Logger.who(newHostUserId, newHost.userName)}`);
|
||||||
|
} else {
|
||||||
|
Logger.party(`left partyId=${party.id} user=${whoLeft} memberCount=${party.members.size}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hostChanged: wasHost && newHostUserId != null, newHostUserId, destroyed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves to the next item in the (fixed, video) queue - e.g. next
|
||||||
|
* episode. Returns false if there's nothing after the current position.
|
||||||
|
* Resets every member's known position/pause state, since it's a new
|
||||||
|
* item playing from zero.
|
||||||
|
*/
|
||||||
|
advanceQueue(party: Party): boolean {
|
||||||
|
if (party.queueIndex >= party.queue.length - 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
party.queueIndex += 1;
|
||||||
|
const next = party.queue[party.queueIndex]!;
|
||||||
|
party.itemId = next.itemId;
|
||||||
|
party.itemName = next.itemName;
|
||||||
|
party.itemImageUrl = next.itemImageUrl;
|
||||||
|
for (const member of party.members.values()) {
|
||||||
|
member.positionTicks = 0;
|
||||||
|
member.isPaused = false;
|
||||||
|
member.lastUpdatedUtc = Date.now();
|
||||||
|
}
|
||||||
|
party.lastActivityUtc = Date.now();
|
||||||
|
Logger.party(`advanced partyId=${party.id} to item="${next.itemName}" (${party.queueIndex + 1}/${party.queue.length})`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All active parties - powers the "watch parties en cours" home section. */
|
||||||
|
listActive(): Party[] {
|
||||||
|
return Array.from(this.partiesById.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
removeIdleParties(idleTimeoutMs: number): void {
|
||||||
|
const cutoff = Date.now() - idleTimeoutMs;
|
||||||
|
for (const party of this.partiesById.values()) {
|
||||||
|
if (party.lastActivityUtc < cutoff) {
|
||||||
|
this.partiesById.delete(party.id);
|
||||||
|
MusicQueueService.destroy(party); // same leak this class's own destroy path guards against
|
||||||
|
const idleMinutes = Math.round((Date.now() - party.lastActivityUtc) / 60000);
|
||||||
|
Logger.party(`destroyed partyId=${party.id} (idle ${idleMinutes}min, ${party.members.size} member(s) still connected)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `viewerUserId` lets the response say "youAreHost" instead of the
|
||||||
|
* client having to know its own Emby user id to figure that out itself -
|
||||||
|
* now that auth is a server-side session (see AuthService), the client
|
||||||
|
* doesn't hold an id to compare with at all.
|
||||||
|
*/
|
||||||
|
toPublicState(party: Party, viewerUserId: string | null): PartyPublicState {
|
||||||
|
const host = party.members.get(party.hostUserId);
|
||||||
|
return {
|
||||||
|
partyId: party.id,
|
||||||
|
mediaType: party.mediaType,
|
||||||
|
itemId: party.itemId,
|
||||||
|
itemName: party.itemName,
|
||||||
|
itemImageUrl: party.itemImageUrl,
|
||||||
|
createdUtc: party.createdUtc,
|
||||||
|
// hasNext/queueLength/queueIndex describe the fixed video episode
|
||||||
|
// queue - meaningless for a music party (see `music` below instead).
|
||||||
|
hasNext: party.queueIndex < party.queue.length - 1,
|
||||||
|
queueLength: party.queue.length,
|
||||||
|
queueIndex: party.queueIndex,
|
||||||
|
hostUserName: host ? host.userName : null,
|
||||||
|
memberCount: party.members.size,
|
||||||
|
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
|
||||||
|
members: Array.from(party.members.values()).map((m) => ({
|
||||||
|
userId: m.userId,
|
||||||
|
userName: m.userName,
|
||||||
|
isHost: m.userId === party.hostUserId,
|
||||||
|
isPaused: m.isPaused,
|
||||||
|
positionTicks: m.positionTicks,
|
||||||
|
connected: !!m.connection
|
||||||
|
})),
|
||||||
|
music: party.mediaType === 'music' ? MusicQueueService.getState(party) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private newMember(userId: string, userName: string): PartyMember {
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
userName,
|
||||||
|
connection: null,
|
||||||
|
isPaused: true,
|
||||||
|
positionTicks: 0,
|
||||||
|
lastUpdatedUtc: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PartyService = new PartyServiceClass();
|
||||||
|
|
@ -1,227 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const { PartyManager } = require('./partyManager');
|
|
||||||
const { MusicQueueService } = require('./musicQueueService');
|
|
||||||
const { partyLog, who } = require('./logger');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Realtime side of the app: one party's own room, plus a shared "lobby" room
|
|
||||||
* that powers the home page's live "watch parties en cours" list.
|
|
||||||
*
|
|
||||||
* Auth comes from the session cookie sent during the initial HTTP handshake -
|
|
||||||
* exposed here via socket.request.headers.cookie.
|
|
||||||
*
|
|
||||||
* Events (client -> server unless noted):
|
|
||||||
* "subscribeLobby" joins the "lobby" room
|
|
||||||
* "parties" (server -> client) [...] full snapshot, sent on subscribe
|
|
||||||
* and again whenever any party changes
|
|
||||||
* "join" {partyId} joins that party's own room
|
|
||||||
* "members" (server -> client) [...] that party's member list
|
|
||||||
*
|
|
||||||
* -- mediaType: 'video' parties --
|
|
||||||
* "playstate" {isPaused, positionTicks} (host only - ignored otherwise)
|
|
||||||
* "playstate" (server -> client, to everyone in the party but the sender)
|
|
||||||
* "next" (host only - advance the queue)
|
|
||||||
* "itemChanged" (server -> client) {itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
|
|
||||||
*
|
|
||||||
* -- mediaType: 'music' parties (see musicQueueService.js for the model) --
|
|
||||||
* "voteSkip" (anyone - see musicQueueService.js for the threshold)
|
|
||||||
* "musicPlaystate" {isPaused} (anyone - no host check, unlike video's "playstate")
|
|
||||||
* "queueUpdated" (server -> client) - see MusicQueueService.getState() for the shape
|
|
||||||
* (adding a track is REST, not a socket event - POST
|
|
||||||
* /api/parties/:partyId/tracks in index.js - since it needs to work
|
|
||||||
* from the album-browsing page too, which has no live connection to
|
|
||||||
* the party it might be adding into; MusicQueueService's own
|
|
||||||
* "queueUpdated" broadcast fires regardless of which path called it)
|
|
||||||
*
|
|
||||||
* -- both --
|
|
||||||
* "hostChanged" (server -> client, to the new host only, no payload)
|
|
||||||
* "requestPause" (throttled per member - see below;
|
|
||||||
* video only - a music party has no
|
|
||||||
* host to ask, everyone already has
|
|
||||||
* direct pause/vote-skip access)
|
|
||||||
* "pauseRequested" (server -> client, everyone in the party) {userId, userName}
|
|
||||||
* "leave"
|
|
||||||
* "errorMessage" (server -> client) {message}
|
|
||||||
* ("error" is a reserved Socket.IO event name - using our own to avoid
|
|
||||||
* colliding with its internal error handling.)
|
|
||||||
*/
|
|
||||||
function attachSocketServer(io, { parseCookies, getSession }) {
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
const cookies = parseCookies(socket.request.headers.cookie);
|
|
||||||
const session = cookies.wp_session ? getSession(cookies.wp_session) : null;
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
socket.emit('errorMessage', { message: 'Session expirée - reconnecte-toi.' });
|
|
||||||
socket.disconnect(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = session.userId;
|
|
||||||
let party = null;
|
|
||||||
|
|
||||||
socket.on('subscribeLobby', () => {
|
|
||||||
socket.join('lobby');
|
|
||||||
socket.emit('parties', lobbySnapshot());
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('join', (msg) => {
|
|
||||||
const p = PartyManager.getById(msg && msg.partyId);
|
|
||||||
const member = p && p.members.get(userId);
|
|
||||||
if (!p || !member) {
|
|
||||||
socket.emit('errorMessage', { message: "Tu n'es pas membre de cette party." });
|
|
||||||
socket.disconnect(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
party = p;
|
|
||||||
member.connection = socket;
|
|
||||||
socket.join(party.id);
|
|
||||||
broadcastMembers(io, party);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('playstate', (msg) => {
|
|
||||||
if (!party || userId !== party.hostUserId) {
|
|
||||||
return; // only the host drives playback - see docs/architecture.md
|
|
||||||
}
|
|
||||||
const member = party.members.get(userId);
|
|
||||||
const wasPaused = member.isPaused;
|
|
||||||
member.isPaused = !!(msg && msg.isPaused);
|
|
||||||
member.positionTicks = Number(msg && msg.positionTicks) || 0;
|
|
||||||
member.lastUpdatedUtc = Date.now();
|
|
||||||
party.lastActivityUtc = Date.now();
|
|
||||||
|
|
||||||
// Logged only on an actual play/pause transition, not every one of
|
|
||||||
// these - the host's player also re-sends the current state on a
|
|
||||||
// plain 3s heartbeat (see player.js), which would otherwise spam this
|
|
||||||
// line for every active party every 3 seconds regardless of whether
|
|
||||||
// anything actually changed.
|
|
||||||
if (member.isPaused !== wasPaused) {
|
|
||||||
const positionSeconds = Math.round(member.positionTicks / 10000000);
|
|
||||||
partyLog(`playstate partyId=${party.id} host=${who(userId, member.userName)} -> ${member.isPaused ? 'paused' : 'playing'} at ${positionSeconds}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.to(party.id).emit('playstate', { isPaused: member.isPaused, positionTicks: member.positionTicks });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('next', () => {
|
|
||||||
if (!party || userId !== party.hostUserId) {
|
|
||||||
return; // only the host can advance the queue
|
|
||||||
}
|
|
||||||
if (PartyManager.advanceQueue(party)) {
|
|
||||||
io.to(party.id).emit('itemChanged', {
|
|
||||||
itemId: party.itemId,
|
|
||||||
itemName: party.itemName,
|
|
||||||
itemImageUrl: party.itemImageUrl,
|
|
||||||
queueIndex: party.queueIndex,
|
|
||||||
queueLength: party.queue.length,
|
|
||||||
hasNext: party.queueIndex < party.queue.length - 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Majority of currently CONNECTED members, not everyone who's ever
|
|
||||||
// joined - see musicQueueService.js.
|
|
||||||
socket.on('voteSkip', () => {
|
|
||||||
const member = party && party.mediaType === 'music' && party.members.get(userId);
|
|
||||||
if (!member) return;
|
|
||||||
const connectedCount = Array.from(party.members.values()).filter((m) => m.connection).length;
|
|
||||||
MusicQueueService.voteSkip(party, userId, member.userName, connectedCount);
|
|
||||||
broadcastLobby(io);
|
|
||||||
});
|
|
||||||
|
|
||||||
// No host check, unlike video's "playstate" - see musicQueueService.js.
|
|
||||||
socket.on('musicPlaystate', (msg) => {
|
|
||||||
if (!party || party.mediaType !== 'music' || !party.members.get(userId)) return;
|
|
||||||
MusicQueueService.setPaused(party, !!(msg && msg.isPaused));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Anyone (not just followers - harmless either way) can ask the host to
|
|
||||||
// pause; the presence overlay (client/js/views/player.js) is what
|
|
||||||
// triggers this. Broadcast to the whole party, not just the host, so
|
|
||||||
// everyone sees why playback is about to pause. Throttled per member
|
|
||||||
// server-side (not just the client's own cooldown, which a stray extra
|
|
||||||
// click - or anyone poking the socket directly - would bypass). Video
|
|
||||||
// only: a music party has no host to ask - everyone already has direct
|
|
||||||
// pause access via "musicPlaystate".
|
|
||||||
socket.on('requestPause', () => {
|
|
||||||
if (!party || party.mediaType === 'music') return;
|
|
||||||
const member = party.members.get(userId);
|
|
||||||
if (!member) return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (member.lastPauseRequestUtc && now - member.lastPauseRequestUtc < 5000) return;
|
|
||||||
member.lastPauseRequestUtc = now;
|
|
||||||
partyLog(`pauseRequested partyId=${party.id} user=${who(userId, member.userName)}`);
|
|
||||||
io.to(party.id).emit('pauseRequested', { userId, userName: member.userName });
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('leave', () => {
|
|
||||||
if (!party) return;
|
|
||||||
applyLeave(io, party, userId);
|
|
||||||
party = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fires on a clean "leave", a dead-connection timeout (Socket.IO's own
|
|
||||||
// heartbeat - see docs/architecture.md), or any other way the socket
|
|
||||||
// goes away - the one cleanup path that always runs, unlike relying on
|
|
||||||
// the client to always get a chance to emit "leave" first.
|
|
||||||
socket.on('disconnect', () => {
|
|
||||||
if (party) applyLeave(io, party, userId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes `userId` from `party` and handles everything that follows from
|
|
||||||
* it: if they were the host, the new host (if any) gets a personal
|
|
||||||
* "hostChanged" so their player can promote itself without a page reload;
|
|
||||||
* either way, the party's own room and the lobby both get refreshed.
|
|
||||||
* Shared by the socket "leave"/"disconnect" handlers above and index.js's
|
|
||||||
* REST leave endpoint so this logic - especially the host handoff - only
|
|
||||||
* has to be right in one place.
|
|
||||||
*/
|
|
||||||
function applyLeave(io, party, userId) {
|
|
||||||
const result = PartyManager.leave(party, userId);
|
|
||||||
if (!result.destroyed) {
|
|
||||||
broadcastMembers(io, party);
|
|
||||||
if (result.hostChanged) {
|
|
||||||
const newHost = party.members.get(result.newHostUserId);
|
|
||||||
if (newHost && newHost.connection) {
|
|
||||||
newHost.connection.emit('hostChanged');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
broadcastLobby(io);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastMembers(io, party) {
|
|
||||||
const members = Array.from(party.members.values()).map((m) => ({
|
|
||||||
userId: m.userId,
|
|
||||||
userName: m.userName,
|
|
||||||
isHost: m.userId === party.hostUserId,
|
|
||||||
connected: !!m.connection
|
|
||||||
}));
|
|
||||||
io.to(party.id).emit('members', members);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lobbySnapshot() {
|
|
||||||
return PartyManager.listActive().map((party) => {
|
|
||||||
const host = party.members.get(party.hostUserId);
|
|
||||||
return {
|
|
||||||
partyId: party.id,
|
|
||||||
mediaType: party.mediaType,
|
|
||||||
itemId: party.itemId,
|
|
||||||
itemName: party.itemName,
|
|
||||||
itemImageUrl: party.itemImageUrl,
|
|
||||||
hostUserName: host ? host.userName : null,
|
|
||||||
memberCount: party.members.size,
|
|
||||||
createdUtc: party.createdUtc // client computes/ticks elapsed time itself from this
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastLobby(io) {
|
|
||||||
io.to('lobby').emit('parties', lobbySnapshot());
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { attachSocketServer, broadcastLobby, applyLeave };
|
|
||||||
294
server/src/types.ts
Normal file
294
server/src/types.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
/**
|
||||||
|
* Shared server-side types. Kept in one file rather than colocated with
|
||||||
|
* each service, since several of these (Session, ItemDto, Party*) are used
|
||||||
|
* by three or more services and colocating would just mean everyone
|
||||||
|
* importing from whichever service happened to "own" the type first.
|
||||||
|
*
|
||||||
|
* Not shared with the client build (client/src/types.ts has its own,
|
||||||
|
* overlapping copies of the DTO shapes) - the two projects compile
|
||||||
|
* independently (tsc here, esbuild there), and a real shared package would
|
||||||
|
* be a bigger structural commitment than this app's size justifies. A
|
||||||
|
* handful of duplicated interfaces across the client/server boundary is a
|
||||||
|
* normal, accepted trade-off for that - see docs/architecture.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Server as IoServer, Socket as IoSocket } from 'socket.io';
|
||||||
|
|
||||||
|
// --- Realtime (Socket.IO) event maps ---------------------------------------
|
||||||
|
// Typed once here rather than left as bare strings at every .emit()/.on()
|
||||||
|
// call site - see SocketGateway, MusicQueueService (which broadcasts
|
||||||
|
// "queueUpdated" directly, independent of SocketGateway's own handlers).
|
||||||
|
|
||||||
|
export interface ClientToServerEvents {
|
||||||
|
subscribeLobby: () => void;
|
||||||
|
join: (msg: { partyId: string }) => void;
|
||||||
|
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
|
||||||
|
next: () => void;
|
||||||
|
voteSkip: () => void;
|
||||||
|
musicPlaystate: (msg: { isPaused: boolean }) => void;
|
||||||
|
requestPause: () => void;
|
||||||
|
leave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The socket "members" broadcast's shape - deliberately smaller than
|
||||||
|
* PartyPublicState.members below (no isPaused/positionTicks): that extra
|
||||||
|
* detail is REST-only (a follower's player only ever reads "playstate"
|
||||||
|
* events for position, never the member list, for that). */
|
||||||
|
export interface SocketMemberDto {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
isHost: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerToClientEvents {
|
||||||
|
parties: (parties: LobbyPartyDto[]) => void;
|
||||||
|
members: (members: SocketMemberDto[]) => void;
|
||||||
|
playstate: (msg: { isPaused: boolean; positionTicks: number }) => void;
|
||||||
|
itemChanged: (msg: {
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
itemImageUrl: string;
|
||||||
|
queueIndex: number;
|
||||||
|
queueLength: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}) => void;
|
||||||
|
queueUpdated: (state: MusicPublicState) => void;
|
||||||
|
hostChanged: () => void;
|
||||||
|
pauseRequested: (msg: { userId: string; userName: string }) => void;
|
||||||
|
errorMessage: (msg: { message: string }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppSocket = IoSocket<ClientToServerEvents, ServerToClientEvents>;
|
||||||
|
export type AppServer = IoServer<ClientToServerEvents, ServerToClientEvents>;
|
||||||
|
|
||||||
|
// --- Auth / session -------------------------------------------------------
|
||||||
|
|
||||||
|
export interface EmbySession {
|
||||||
|
embyToken: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
serverId: string;
|
||||||
|
createdUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Augments Express's own Request type (declaration merging) so
|
||||||
|
// `req.embySession` is known everywhere without a separate "AuthedRequest"
|
||||||
|
// type at every route - set once by requireSession in index.ts.
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
embySession?: EmbySession;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Catalog ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Emby's own item shape, as returned by /Users/{id}/Items and friends -
|
||||||
|
* only the fields this app actually reads, not the full Emby schema. */
|
||||||
|
export interface EmbyItem {
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
Overview?: string;
|
||||||
|
ProductionYear?: number;
|
||||||
|
Type?: string;
|
||||||
|
SeriesId?: string;
|
||||||
|
SeriesName?: string;
|
||||||
|
ParentIndexNumber?: number;
|
||||||
|
IndexNumber?: number;
|
||||||
|
RunTimeTicks?: number;
|
||||||
|
Album?: string;
|
||||||
|
AlbumArtist?: string;
|
||||||
|
Artists?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This app's own DTO shape - what toItemDto() in CatalogService produces,
|
||||||
|
* sent to the client for every item type (movie/episode/series/artist/
|
||||||
|
* album/track/musicvideo alike). Track-specific fields are present but
|
||||||
|
* meaningless for anything else. */
|
||||||
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Party (video) -----------------------------------------------------------
|
||||||
|
|
||||||
|
/** One entry in a video party's fixed, pre-built queue (a movie/standalone
|
||||||
|
* episode is just a one-item queue) - see PartyService.create. */
|
||||||
|
export interface QueueItem {
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
itemImageUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaType = 'video' | 'music';
|
||||||
|
|
||||||
|
export interface PartyMember {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
connection: AppSocket | null;
|
||||||
|
isPaused: boolean;
|
||||||
|
positionTicks: number;
|
||||||
|
lastUpdatedUtc: number;
|
||||||
|
lastPauseRequestUtc?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The full internal party record - never sent to the client as-is, see
|
||||||
|
* PartyService.toPublicState for the shape that actually goes out. */
|
||||||
|
export interface Party {
|
||||||
|
id: string;
|
||||||
|
mediaType: MediaType;
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
itemImageUrl: string;
|
||||||
|
queue: QueueItem[];
|
||||||
|
queueIndex: number;
|
||||||
|
hostUserId: string;
|
||||||
|
createdUtc: number;
|
||||||
|
lastActivityUtc: number;
|
||||||
|
members: Map<string, PartyMember>;
|
||||||
|
/** Only present once MusicQueueService.init() has run - i.e. mediaType
|
||||||
|
* === 'music'. Owned and shaped entirely by MusicQueueService; every
|
||||||
|
* other service treats this as an opaque bag it just carries around. */
|
||||||
|
music?: MusicState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicMember {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
isHost: boolean;
|
||||||
|
isPaused: boolean;
|
||||||
|
positionTicks: number;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What GET /api/parties/:id and POST /api/parties actually return - the
|
||||||
|
* video queue fields (hasNext/queueLength/queueIndex) are meaningless for
|
||||||
|
* a music party, which carries `music` instead. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LobbyPartyDto {
|
||||||
|
partyId: string;
|
||||||
|
mediaType: MediaType;
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
itemImageUrl: string;
|
||||||
|
hostUserName: string | null;
|
||||||
|
memberCount: number;
|
||||||
|
createdUtc: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaveResult {
|
||||||
|
hostChanged: boolean;
|
||||||
|
newHostUserId: string | null;
|
||||||
|
destroyed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Music (participative queue) --------------------------------------------
|
||||||
|
|
||||||
|
/** One track in a music party's shared, mutable queue - see
|
||||||
|
* MusicQueueService. Distinct from QueueItem above: that one is fixed and
|
||||||
|
* built once server-side, this one grows live as members add to it. */
|
||||||
|
export interface MusicTrack {
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
artistName: string | null;
|
||||||
|
albumName: string | null;
|
||||||
|
itemImageUrl: string;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
addedByUserId: string;
|
||||||
|
addedByUserName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a client actually sends when adding a track (POST
|
||||||
|
* /api/parties/:id/tracks) - Emby-tick-shaped, converted to
|
||||||
|
* MusicTrack.durationSeconds once by MusicQueueService.addTrack. */
|
||||||
|
export interface MusicTrackInput {
|
||||||
|
itemId: string;
|
||||||
|
itemName: string;
|
||||||
|
artistName?: string | null;
|
||||||
|
albumName?: string | null;
|
||||||
|
itemImageUrl: string;
|
||||||
|
durationTicks?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicCurrentTrack {
|
||||||
|
track: MusicTrack;
|
||||||
|
startedUtc: number;
|
||||||
|
isPaused: boolean;
|
||||||
|
/** Elapsed ms at the moment it was paused - null while playing. Combined
|
||||||
|
* with `isPaused`, this is the only state MusicQueueService.getElapsedSeconds
|
||||||
|
* needs; there is no separate ticking "position" stored anywhere. */
|
||||||
|
pausedElapsedMs: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** party.music - owned entirely by MusicQueueService (see its class
|
||||||
|
* comment for why this is a separate service rather than living in
|
||||||
|
* PartyService alongside the video queue fields). */
|
||||||
|
export interface MusicState {
|
||||||
|
queue: MusicTrack[];
|
||||||
|
current: MusicCurrentTrack | null;
|
||||||
|
skipVotes: Set<string>;
|
||||||
|
advanceTimer: NodeJS.Timeout | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MusicCurrentPublic extends MusicTrack {
|
||||||
|
isPaused: boolean;
|
||||||
|
elapsedSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "queueUpdated" broadcast payload / party.music's public shape. */
|
||||||
|
export interface MusicPublicState {
|
||||||
|
queue: MusicTrack[];
|
||||||
|
current: MusicCurrentPublic | null;
|
||||||
|
skipVotes: number;
|
||||||
|
}
|
||||||
18
server/tsconfig.json
Normal file
18
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": false
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue