From 5182def5c9ab8e202ad20347a3be8ca6175b30c4 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 24 Sep 2026 18:54:47 +0200 Subject: [PATCH] Add static (persistent) watch parties A watch party built from a whole series or video playlist that remembers the group's progress and each member's, so someone coming back or joining late sees the items they missed and can catch up (solo, as a catch-up watch party, or by marking items watched). - StaticPartyService + StaticPartyStore: first on-disk state (JSON file under DATA_DIR, atomic debounced writes, flushed on SIGTERM) - each session is an ordinary Party linked via party.staticLink; group position is saved by the 20s sweep and resumed on the next session - PartyService.completeCurrentItem centralizes "item done" (Emby Played + static progress); new host-only "itemEnded" socket event covers the last queued item, which was never marked Played before - missed = group-completed minus watched in-app minus Emby Played - /static/:id view, create buttons on series/playlist pages, home section - Docker volume for /data, spec/static-watch-party.md Co-Authored-By: Claude Opus 5.5 --- .gitignore | 3 + README.md | 9 +- client/src/api/party.ts | 6 + client/src/api/staticParty.ts | 74 ++++ client/src/app.ts | 2 + client/src/types.ts | 36 ++ client/src/views/home/Home.css | 38 +++ client/src/views/home/Home.html | 5 + client/src/views/home/Home.ts | 52 ++- client/src/views/player/Player.ts | 5 +- client/src/views/playlist/Playlist.css | 3 +- client/src/views/playlist/Playlist.html | 1 + client/src/views/playlist/Playlist.ts | 10 + client/src/views/series/Series.css | 8 + client/src/views/series/Series.html | 5 +- client/src/views/series/Series.ts | 2 + client/src/views/staticParty/StaticParty.css | 138 ++++++++ client/src/views/staticParty/StaticParty.html | 39 +++ client/src/views/staticParty/StaticParty.ts | 219 ++++++++++++ .../views/staticParty/createStaticButton.ts | 28 ++ docker-compose.yml | 6 + docs/architecture.md | 33 +- server/Dockerfile | 4 + server/package.json | 2 +- server/src/Logger.ts | 7 + server/src/config.ts | 8 + server/src/index.ts | 258 +++++++++++++- server/src/realtime/SocketGateway.ts | 24 +- server/src/services/PartyService.ts | 53 ++- server/src/services/StaticPartyService.ts | 316 ++++++++++++++++++ server/src/services/StaticPartyStore.ts | 146 ++++++++ server/src/types.ts | 108 ++++++ server/test/staticPartyService.test.ts | 234 +++++++++++++ spec/static-watch-party.md | 128 +++++++ 34 files changed, 1974 insertions(+), 36 deletions(-) create mode 100644 client/src/api/staticParty.ts create mode 100644 client/src/views/staticParty/StaticParty.css create mode 100644 client/src/views/staticParty/StaticParty.html create mode 100644 client/src/views/staticParty/StaticParty.ts create mode 100644 client/src/views/staticParty/createStaticButton.ts create mode 100644 server/src/services/StaticPartyService.ts create mode 100644 server/src/services/StaticPartyStore.ts create mode 100644 server/test/staticPartyService.test.ts create mode 100644 spec/static-watch-party.md diff --git a/.gitignore b/.gitignore index 42855a3..c624514 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ server/dist/ client/dist/ *.tsbuildinfo +## Runtime data (static watch parties - see server/src/services/StaticPartyStore.ts) +data/ + ## OS cruft .DS_Store Thumbs.db diff --git a/README.md b/README.md index 6e8bd76..2127905 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,7 @@ server/ Node.js + Express + Socket.IO backend: proxies ALL Emby tsc to server/dist. src/index.ts Express app + route wiring only - thin, delegates to the services below - src/config.ts env-derived config (EMBY_SERVER_URL, PORT) + src/config.ts env-derived config (EMBY_SERVER_URL, PORT, DATA_DIR) src/types.ts shared interfaces (Party, ItemDto, the Socket.IO event maps, ...) src/Logger.ts party/stream lifecycle logging @@ -245,9 +245,14 @@ build stage, so nothing needs building beforehand: ```bash docker build -f server/Dockerfile -t watchparty . -docker run -d --name watchparty -p 3000:3000 -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty +docker run -d --name watchparty -p 3000:3000 -v "$(pwd)/data:/data" -e EMBY_SERVER_URL=https://theater.media.kyuno.fr watchparty ``` +The `/data` volume holds the static (persistent) watch parties +(`DATA_DIR`, see [`spec/static-watch-party.md`](spec/static-watch-party.md)). +Without it they're lost whenever the container is recreated. +`docker-compose.yml` already mounts it. + Then put it behind your existing reverse proxy like everything else (`theater.media.kyuno.fr`, `git.dev.kyuno.fr`, ...), e.g. `watchparty.kyuno.fr` → `http://:3000`. diff --git a/client/src/api/party.ts b/client/src/api/party.ts index 181cf67..7820ca3 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -150,6 +150,9 @@ export interface PartyConnection { sendPlaystate(isPaused: boolean, positionTicks: number): void; /** Host-only: advance the party's queue (e.g. to the next episode). */ next(): void; + /** Host-only: the last queued item played to its end - lets the server + * count it as watched (there's no "next" to send for it). */ + itemEnded(): 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; @@ -230,6 +233,9 @@ export function connectParty(partyId: string, callbacks: PartyConnectionCallback next() { socket.emit('next'); }, + itemEnded() { + socket.emit('itemEnded'); + }, requestPause() { socket.emit('requestPause'); }, diff --git a/client/src/api/staticParty.ts b/client/src/api/staticParty.ts new file mode 100644 index 0000000..870f73f --- /dev/null +++ b/client/src/api/staticParty.ts @@ -0,0 +1,74 @@ +// Static (persistent) watch parties - REST only, same origin, session +// cookie auth (see embyApi.ts). A static party's live sessions are +// ordinary parties: once one of these calls returns a partyId, everything +// else goes through api/party.ts and the normal player, unchanged. See +// server/src/services/StaticPartyService.ts and spec/static-watch-party.md. +import type { StaticPartyDetailDto, StaticPartySummaryDto } from '../types'; + +/** Same contract as api/party.ts's own helper: JSON in/out, the server's + * `error` message thrown as-is (they're written to be shown to users). */ +async function apiFetch(path: string, options: RequestInit = {}): Promise { + let res: Response; + try { + res = await fetch(path, { + credentials: 'same-origin', + ...options, + headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } + }); + } catch (err) { + console.error('[staticParty] network error', path, err); + throw new Error('Serveur injoignable.'); + } + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.warn('[staticParty] request failed', path, res.status, body.error); + throw new Error(body.error || `Request failed: ${res.status}`); + } + return res.json() as Promise; +} + +/** Every static watch party, with the viewer's own missed count. */ +export async function listStaticParties(): Promise { + const data = await apiFetch<{ staticParties: StaticPartySummaryDto[] }>('/api/static-parties'); + return data.staticParties; +} + +/** Creates one from a series or a video playlist (`sourceId`). */ +export function createStaticParty(sourceId: string, name?: string): Promise { + return apiFetch('/api/static-parties', { method: 'POST', body: JSON.stringify({ sourceId, name }) }); +} + +/** Everything about one static watch party, from the viewer's side. */ +export function getStaticParty(id: string): Promise { + return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`); +} + +/** Become a member. */ +export function joinStaticParty(id: string): Promise<{ ok: true }> { + return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/join`, { method: 'POST' }); +} + +/** Stop being a member (drops the viewer's own progress). */ +export function leaveStaticParty(id: string): Promise<{ ok: true }> { + return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/leave`, { method: 'POST' }); +} + +/** Delete it for everyone - creator only. */ +export function deleteStaticParty(id: string): Promise<{ ok: true }> { + return apiFetch(`/api/static-parties/${encodeURIComponent(id)}`, { method: 'DELETE' }); +} + +/** "Marquer comme vu". */ +export function markStaticItemWatched(id: string, itemId: string): Promise<{ ok: true }> { + return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/items/${encodeURIComponent(itemId)}/watched`, { method: 'POST' }); +} + +/** Launches the group's next session, or joins the one running. */ +export function startStaticSession(id: string): Promise<{ partyId: string }> { + return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/session`, { method: 'POST' }); +} + +/** Catches up on `itemIds` - alone, or as a watch party others can join. */ +export function startCatchUp(id: string, itemIds: string[], mode: 'solo' | 'party'): Promise<{ partyId: string }> { + return apiFetch(`/api/static-parties/${encodeURIComponent(id)}/catch-up`, { method: 'POST', body: JSON.stringify({ itemIds, mode }) }); +} diff --git a/client/src/app.ts b/client/src/app.ts index f9b18a8..442c155 100644 --- a/client/src/app.ts +++ b/client/src/app.ts @@ -10,6 +10,7 @@ import { renderArtist } from './views/artist/Artist'; import { renderAlbum } from './views/album/Album'; import { renderPlaylist } from './views/playlist/Playlist'; import { renderPartyPlayer } from './views/player/Player'; +import { renderStaticParty } from './views/staticParty/StaticParty'; route('/', () => navigate('/home')); route('/login', renderLogin); @@ -20,6 +21,7 @@ route('/artist/:id', renderArtist); route('/album/:id', renderAlbum); route('/playlist/:id', renderPlaylist); route('/party/:id', renderPartyPlayer); +route('/static/:id', renderStaticParty); /** * Renders either the bare login screen or the full shell (sidebar + diff --git a/client/src/types.ts b/client/src/types.ts index 878106e..8d0924d 100644 --- a/client/src/types.ts +++ b/client/src/types.ts @@ -171,6 +171,42 @@ export interface LobbyPartyDto { positionTicks: number | null; isPaused: boolean | null; positionUpdatedUtc: number | null; + /** The static watch party this live session belongs to, if any. */ + staticPartyId: string | null; + staticPartyName: string | null; +} + +/** Mirrors server/src/types.ts's StaticPartySummaryDto - one static + * (persistent) watch party, as the home page lists it. */ +export interface StaticPartySummaryDto { + id: string; + name: string; + sourceType: 'series' | 'playlist'; + imageUrl: string; + itemCount: number; + groupWatchedCount: number; + nextItemName: string | null; + memberCount: number; + youAreMember: boolean; + youAreCreator: boolean; + missedCount: number; + livePartyId: string | null; +} + +/** One item of a static watch party - mirrors StaticPartyItemDto. */ +export interface StaticPartyItemDto { + itemId: string; + itemName: string; + itemImageUrl: string; + groupState: 'watched' | 'next' | 'upcoming'; + viewerWatched: boolean; +} + +/** Mirrors server/src/types.ts's StaticPartyDetailDto. */ +export interface StaticPartyDetailDto extends StaticPartySummaryDto { + items: StaticPartyItemDto[]; + missedItemIds: string[]; + members: Array<{ userId: string; userName: string; watchedCount: number; isCreator: boolean }>; } export interface ItemChangedMsg { diff --git a/client/src/views/home/Home.css b/client/src/views/home/Home.css index 2b77ab6..c1fd437 100644 --- a/client/src/views/home/Home.css +++ b/client/src/views/home/Home.css @@ -60,3 +60,41 @@ color: var(--accent-bright); font-weight: 600; } + +/* ---- Static (persistent) watch parties ---- + A whole card is the link (to /static/:id) - reuses .partyCard's layout, + minus the default anchor styling. */ +.staticCard { + color: inherit; + text-decoration: none; +} + +.staticCard:hover { background: #2b252e; } + +.staticCardLive { + color: var(--accent-bright); + font-size: 12px; + font-weight: 600; +} + +.staticCardMissed { + flex-shrink: 0; + padding: 4px 10px; + border-radius: 12px; + background: var(--danger); + color: #fff; + font-size: 12px; + font-weight: 600; +} + +/* A live session's link back to the static watch party it belongs to. */ +.partyCardStatic { + color: var(--accent-bright); + font-size: 12px; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.partyCardStatic:hover { text-decoration: underline; } diff --git a/client/src/views/home/Home.html b/client/src/views/home/Home.html index a4f6f9c..de9b9b6 100644 --- a/client/src/views/home/Home.html +++ b/client/src/views/home/Home.html @@ -7,4 +7,9 @@ + +
diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts index a52e09c..3aac85f 100644 --- a/client/src/views/home/Home.ts +++ b/client/src/views/home/Home.ts @@ -7,7 +7,8 @@ import { navigate } from '../../router'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; import { LOADING_HINT } from '../../loading'; import { showToast } from '../../toast'; -import type { LobbyPartyDto } from '../../types'; +import { listStaticParties } from '../../api/staticParty'; +import type { LobbyPartyDto, StaticPartySummaryDto } from '../../types'; export function renderHome(root: HTMLElement): () => void { root.innerHTML = html; @@ -18,7 +19,20 @@ export function renderHome(root: HTMLElement): () => void { // label is the one piece that ticks purely client-side (from // createdUtc) - no point re-pushing from the server once a second just // for a clock. - const lobby = connectLobby((parties) => renderParties(root, parties)); + // Static watch parties are a REST fetch (their data changes rarely), but + // re-fetched whenever the set of live static-party sessions in the lobby + // changes - a session starting/ending is what moves their cards' + // "en cours"/progress state. Not on every lobby snapshot: those also + // fire on every pause/join, and each static-party fetch reads Emby. + let staticSessionsKey: string | null = null; + const lobby = connectLobby((parties) => { + renderParties(root, parties); + const key = parties.map((p) => p.staticPartyId).filter(Boolean).sort().join(','); + if (key !== staticSessionsKey) { + staticSessionsKey = key; + loadStaticParties(root); + } + }); const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000); loadRecentSections(root); @@ -58,6 +72,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
${escapeHtml(p.itemName || '')} + ${p.staticPartyId ? `↻ ${escapeHtml(p.staticPartyName || '')}` : ''} ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} ·
diff --git a/client/src/views/playlist/Playlist.ts b/client/src/views/playlist/Playlist.ts index 760d200..bee2bb5 100644 --- a/client/src/views/playlist/Playlist.ts +++ b/client/src/views/playlist/Playlist.ts @@ -2,6 +2,7 @@ import './Playlist.css'; import html from './Playlist.html'; import { getItem, getPlaylistItems } from '../../api/embyApi'; import { createParty } from '../../api/party'; +import { wireCreateStaticButton } from '../staticParty/createStaticButton'; import { joinMusicParty } from '../../musicBar/MusicBar'; import { navigate } from '../../router'; import type { RouteParams } from '../../router'; @@ -56,6 +57,15 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr return; } + // Video playlists only - a static watch party has no music equivalent + // (the server refuses one too). Same "first item decides" rule as + // CatalogService.splitPlaylistByMediaType. + if (items[0]!.type !== 'Audio') { + const createStaticBtn = root.querySelector('#createStaticBtn') as HTMLButtonElement; + createStaticBtn.classList.remove('hide'); + wireCreateStaticButton(createStaticBtn, playlistId); + } + listEl.innerHTML = items.map((item, i) => { const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · '); const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : ''; diff --git a/client/src/views/series/Series.css b/client/src/views/series/Series.css index 19a7d20..aba8b10 100644 --- a/client/src/views/series/Series.css +++ b/client/src/views/series/Series.css @@ -17,6 +17,14 @@ .seriesPoster.noImage { visibility: hidden; } +.seriesActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; + margin-top: 8px; +} + @media (max-width: 600px) { .seriesHeader { flex-direction: column; } .seriesPoster { width: 120px; } diff --git a/client/src/views/series/Series.html b/client/src/views/series/Series.html index b6d142b..ab5e41a 100644 --- a/client/src/views/series/Series.html +++ b/client/src/views/series/Series.html @@ -4,7 +4,10 @@

- Voir sur Emby +
+ + Voir sur Emby +
diff --git a/client/src/views/series/Series.ts b/client/src/views/series/Series.ts index 69eb9fb..6b73ca0 100644 --- a/client/src/views/series/Series.ts +++ b/client/src/views/series/Series.ts @@ -2,6 +2,7 @@ import './Series.css'; import html from './Series.html'; import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi'; import { createParty } from '../../api/party'; +import { wireCreateStaticButton } from '../staticParty/createStaticButton'; import { navigate } from '../../router'; import type { RouteParams } from '../../router'; import { escapeHtml } from '../../tiles'; @@ -34,6 +35,7 @@ export async function renderSeries(root: HTMLElement, params: RouteParams): Prom overviewEl.classList.remove('hide'); } (root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl; + wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, seriesId); if (!seasons.length) { root.querySelector('#episodeList')!.innerHTML = '

Aucune saison trouvée.

'; diff --git a/client/src/views/staticParty/StaticParty.css b/client/src/views/staticParty/StaticParty.css new file mode 100644 index 0000000..440b59c --- /dev/null +++ b/client/src/views/staticParty/StaticParty.css @@ -0,0 +1,138 @@ +/* ---- Static (persistent) watch party page ---- + .seriesInfo/.episodeList/.episodeRow/.episodeInfo are reused as-is from + views/series/Series.css (always in the bundle - same precedent as + Playlist.css/Album.css). Only what's specific to this page lives here. */ + +.staticHeader { + display: flex; + gap: 20px; + margin-bottom: 28px; +} + +.staticCover { + width: 160px; + aspect-ratio: 2 / 3; + object-fit: cover; + border-radius: 8px; + background: var(--panel); + flex-shrink: 0; +} + +.staticCover.noImage { visibility: hidden; } + +.staticBadge { + display: inline-block; + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--accent-bright); + margin-bottom: 6px; +} + +.staticMeta { margin: 6px 0 10px; } + +.staticProgress { + height: 6px; + max-width: 360px; + border-radius: 3px; + background: var(--panel); + overflow: hidden; +} + +.staticProgressFill { + height: 100%; + width: 0; + background: var(--accent); + transition: width .3s; +} + +.staticNext { margin: 10px 0 14px; font-size: 14px; } + +.staticActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px 16px; +} + +/* A quieter variant of the global green button - for actions next to a + primary one (joining, catching up) that shouldn't compete with it. */ +.secondaryBtn { + background: var(--panel); + color: var(--text); +} + +.secondaryBtn:hover { background: #333; } + +.staticSectionHead { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 8px; +} + +.staticSectionHead h2 { margin: 0; } + +.staticCount { + display: inline-block; + min-width: 22px; + padding: 1px 7px; + margin-left: 6px; + border-radius: 11px; + background: var(--danger); + color: #fff; + font-size: 12px; + text-align: center; +} + +.staticBulkActions { display: flex; flex-wrap: wrap; gap: 8px; } +.staticBulkActions button, +.staticRowActions button { padding: 8px 12px; font-size: 13px; } + +.staticMissed .episodeRow { border-left: 3px solid var(--danger); } + +.staticCheck { + width: 18px; + height: 18px; + margin: 0; + flex-shrink: 0; + accent-color: var(--accent); +} + +.staticRowActions { display: flex; flex-wrap: wrap; gap: 8px; flex-shrink: 0; } + +/* Group-progress list: what the group has seen fades back, the next item + stands out. */ +.episodeRow.isWatched { opacity: .55; } +.episodeRow.isNext { outline: 2px solid var(--accent); } + +.staticState { + flex-shrink: 0; + font-size: 12px; + font-weight: 600; + color: var(--muted); + text-align: right; +} + +.staticState .mine { display: block; color: var(--accent-bright); font-weight: 400; } +.episodeRow.isNext .staticState { color: var(--accent-bright); } + +.staticMembers { display: flex; flex-wrap: wrap; gap: 8px; } + +.staticMember { + background: var(--bg-header); + border-radius: 16px; + padding: 6px 12px; + font-size: 13px; +} + +.staticMember .hint { margin: 0 0 0 6px; font-size: 12px; } + +@media (max-width: 600px) { + .staticHeader { flex-direction: column; } + .staticCover { width: 120px; } + .staticRowActions { flex-basis: 100%; order: 4; } +} diff --git a/client/src/views/staticParty/StaticParty.html b/client/src/views/staticParty/StaticParty.html new file mode 100644 index 0000000..85ae4b5 --- /dev/null +++ b/client/src/views/staticParty/StaticParty.html @@ -0,0 +1,39 @@ + +
+ +
+ Watch party permanente +

+

+ +

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

Membres

+
+
+ +
+

Progression du groupe

+
+
diff --git a/client/src/views/staticParty/StaticParty.ts b/client/src/views/staticParty/StaticParty.ts new file mode 100644 index 0000000..5d4ce71 --- /dev/null +++ b/client/src/views/staticParty/StaticParty.ts @@ -0,0 +1,219 @@ +import './StaticParty.css'; +import html from './StaticParty.html'; +import { + deleteStaticParty, + getStaticParty, + joinStaticParty, + leaveStaticParty, + markStaticItemWatched, + startCatchUp, + startStaticSession +} from '../../api/staticParty'; +import { navigate } from '../../router'; +import type { RouteParams } from '../../router'; +import { escapeHtml } from '../../tiles'; +import { LOADING_HINT } from '../../loading'; +import { showToast } from '../../toast'; +import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types'; + +/** + * Route `/static/:id` - one static (persistent) watch party: the group's + * progress through the whole series/playlist, its members, and - the + * point of the feature - what *you* missed, with ways to catch up (alone, + * or as a watch party others can join) or mark things as already seen. + * Launching the group's session hands off to the normal player + * (`/party/:id`) - a session is an ordinary party server-side. See + * server/src/services/StaticPartyService.ts and spec/static-watch-party.md. + */ +export async function renderStaticParty(root: HTMLElement, params: RouteParams): Promise { + const staticId = params.id as string; + root.innerHTML = LOADING_HINT; + + let detail: StaticPartyDetailDto; + try { + detail = await getStaticParty(staticId); + } catch (err) { + console.error('[staticParty] load failed', staticId, err); + root.innerHTML = `

${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}

`; + return; + } + + root.innerHTML = html; + root.querySelector('#back')!.addEventListener('click', () => history.back()); + wireActions(root, staticId); + render(root, detail); +} + +/** Re-fetches and re-renders in place - after any action that changed + * progress or membership. */ +async function reload(root: HTMLElement, staticId: string): Promise { + try { + const detail = await getStaticParty(staticId); + if (!root.querySelector('#staticTitle')) return; // navigated away mid-flight + render(root, detail); + } catch (err) { + console.error('[staticParty] reload failed', staticId, err); + showToast((err as Error).message, { variant: 'error' }); + } +} + +/** Fills every part of the page from `detail`. */ +function render(root: HTMLElement, detail: StaticPartyDetailDto): void { + (root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl; + root.querySelector('#staticTitle')!.textContent = detail.name; + const sourceLabel = detail.sourceType === 'series' ? 'Série' : 'Playlist'; + root.querySelector('#staticMeta')!.textContent = + `${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`; + const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0; + (root.querySelector('#staticProgressFill') as HTMLElement).style.width = `${percent}%`; + root.querySelector('#staticNext')!.innerHTML = detail.nextItemName + ? `Prochain : ${escapeHtml(detail.nextItemName)}` + : 'Le groupe a tout vu.'; + + const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement; + sessionBtn.textContent = detail.livePartyId ? 'Rejoindre la séance en cours' : 'Lancer la séance'; + sessionBtn.disabled = !detail.livePartyId && !detail.nextItemName; + root.querySelector('#joinBtn')!.classList.toggle('hide', detail.youAreMember); + root.querySelector('#leaveBtn')!.classList.toggle('hide', !detail.youAreMember || detail.youAreCreator); + root.querySelector('#deleteBtn')!.classList.toggle('hide', !detail.youAreCreator); + + renderMissed(root, detail); + renderMembers(root, detail); + renderItems(root, detail); +} + +/** "Tu as manqué" - hidden entirely when there's nothing to catch up on. */ +function renderMissed(root: HTMLElement, detail: StaticPartyDetailDto): void { + const section = root.querySelector('#missedSection') as HTMLElement; + const missed = new Set(detail.missedItemIds); + const items = detail.items.filter((i) => missed.has(i.itemId)); + section.hidden = !items.length; + root.querySelector('#missedCount')!.textContent = String(items.length); + root.querySelector('#missedList')!.innerHTML = items.map((item) => ` +
+ + +
${escapeHtml(item.itemName)}
+
+ + +
+
`).join(''); +} + +/** One chip per member, with how many items they've watched. */ +function renderMembers(root: HTMLElement, detail: StaticPartyDetailDto): void { + root.querySelector('#memberList')!.innerHTML = detail.members.length + ? detail.members.map((m) => ` + ${escapeHtml(m.userName)}${m.isCreator ? ' ★' : ''}${m.watchedCount}/${detail.itemCount}`).join('') + : '

Aucun membre pour le moment.

'; +} + +/** The whole item list, with the group's state and the viewer's own. */ +function renderItems(root: HTMLElement, detail: StaticPartyDetailDto): void { + root.querySelector('#itemList')!.innerHTML = detail.items.map((item, i) => ` +
+ +
${i + 1}. ${escapeHtml(item.itemName)}
+ ${stateLabel(item)}${item.viewerWatched ? '✓ vu par toi' : ''} +
`).join(''); +} + +/** The group-state label for one item. */ +function stateLabel(item: StaticPartyItemDto): string { + if (item.groupState === 'watched') return 'Vu par le groupe'; + if (item.groupState === 'next') return 'Prochain'; + return 'À venir'; +} + +/** + * Wires every button once - lists are re-rendered on reload, so row + * buttons use one delegated listener on their container rather than + * per-row listeners re-added on every render. + */ +function wireActions(root: HTMLElement, staticId: string): void { + const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement; + sessionBtn.addEventListener('click', () => { + runBusy(sessionBtn, async () => { + const { partyId } = await startStaticSession(staticId); + navigate(`/party/${partyId}`); + }); + }); + + const joinBtn = root.querySelector('#joinBtn') as HTMLButtonElement; + joinBtn.addEventListener('click', () => { + runBusy(joinBtn, async () => { + await joinStaticParty(staticId); + showToast('Tu fais maintenant partie de cette watch party.'); + await reload(root, staticId); + }); + }); + + const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement; + leaveBtn.addEventListener('click', () => { + if (!confirm('Quitter cette watch party ? Ta progression y sera oubliée.')) return; + runBusy(leaveBtn, async () => { + await leaveStaticParty(staticId); + await reload(root, staticId); + }); + }); + + const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement; + deleteBtn.addEventListener('click', () => { + if (!confirm('Supprimer cette watch party pour tout le monde ? La progression de chacun sera perdue.')) return; + runBusy(deleteBtn, async () => { + await deleteStaticParty(staticId); + showToast('Watch party supprimée.'); + navigate('/home'); + }); + }); + + const catchUp = (btn: HTMLButtonElement, mode: 'solo' | 'party') => { + const itemIds = Array.from(root.querySelectorAll('#missedList .staticCheck')) + .filter((c) => c.checked) + .map((c) => c.dataset.itemId as string); + if (!itemIds.length) { + showToast('Coche au moins un élément à rattraper.', { variant: 'error' }); + return; + } + runBusy(btn, async () => { + const { partyId } = await startCatchUp(staticId, itemIds, mode); + navigate(`/party/${partyId}`); + }); + }; + const soloBtn = root.querySelector('#catchUpSoloBtn') as HTMLButtonElement; + const partyBtn = root.querySelector('#catchUpPartyBtn') as HTMLButtonElement; + soloBtn.addEventListener('click', () => catchUp(soloBtn, 'solo')); + partyBtn.addEventListener('click', () => catchUp(partyBtn, 'party')); + + root.querySelector('#missedList')!.addEventListener('click', (e) => { + const btn = (e.target as HTMLElement).closest('button[data-item-id]'); + if (!btn) return; + const itemId = btn.dataset.itemId as string; + if (btn.classList.contains('soloOneBtn')) { + runBusy(btn, async () => { + const { partyId } = await startCatchUp(staticId, [itemId], 'solo'); + navigate(`/party/${partyId}`); + }); + } else if (btn.classList.contains('markWatchedBtn')) { + runBusy(btn, async () => { + await markStaticItemWatched(staticId, itemId); + await reload(root, staticId); + }); + } + }); +} + +/** Disables `btn` while `action` runs; a failure is toasted, logged, and + * gives the button back. */ +function runBusy(btn: HTMLButtonElement, action: () => Promise): void { + btn.disabled = true; + action() + .catch((err: unknown) => { + console.error('[staticParty] action failed', err); + showToast((err as Error).message, { variant: 'error' }); + }) + .finally(() => { + btn.disabled = false; + }); +} diff --git a/client/src/views/staticParty/createStaticButton.ts b/client/src/views/staticParty/createStaticButton.ts new file mode 100644 index 0000000..3e38a9b --- /dev/null +++ b/client/src/views/staticParty/createStaticButton.ts @@ -0,0 +1,28 @@ +import { createStaticParty } from '../../api/staticParty'; +import { navigate } from '../../router'; +import { showToast } from '../../toast'; + +/** + * "Créer une watch party permanente" - shared by views/series and + * views/playlist: creates a static watch party over `sourceId` (a series + * or a video playlist - the server decides which, and refuses anything + * else with a message) and opens its page. + */ +export function wireCreateStaticButton(btn: HTMLButtonElement, sourceId: string): void { + const idleLabel = btn.textContent || ''; + btn.addEventListener('click', () => { + btn.disabled = true; + btn.textContent = 'Création...'; + createStaticParty(sourceId) + .then((sp) => { + console.info('[staticParty] created', sp.id, 'from', sourceId); + navigate(`/static/${sp.id}`); + }) + .catch((err: unknown) => { + console.error('[staticParty] create failed', sourceId, err); + showToast((err as Error).message, { variant: 'error' }); + btn.disabled = false; + btn.textContent = idleLabel; + }); + }); +} diff --git a/docker-compose.yml b/docker-compose.yml index 6d97510..3e0643c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,3 +8,9 @@ services: - "3000:3000" environment: EMBY_SERVER_URL: "https://theater.media.kyuno.fr" + # Static (persistent) watch parties are saved here - see + # server/src/services/StaticPartyStore.ts. Without the volume below + # they'd be lost every time the container is recreated. + DATA_DIR: "/data" + volumes: + - ./data:/data diff --git a/docs/architecture.md b/docs/architecture.md index cbc4996..3353a57 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4562,6 +4562,34 @@ would have overflowed by well over 200px. A separate portrait check (375x700) confirmed this landscape block changes nothing there: still `column`, still the original 220px `vw`-capped art, unaffected. +## Static (persistent) watch parties + +Asked for directly: a watch party built from a whole series or video +playlist that remembers the group's progress *and* each person's, so +someone coming back or joining late sees what they missed. Full design +in [`spec/static-watch-party.md`](../spec/static-watch-party.md). The +short version: + +- **A new persistent entity, not a change to Party.** + `StaticPartyService` owns the group and member progress. Each session + is an ordinary live `Party` linked back through `party.staticLink`, + so the player, sync, shared stream, lobby and host handoff are all + reused unchanged. `PartyService` calls into `StaticPartyService`, + never the other way around. +- **This app's first on-disk state.** A JSON file + (`StaticPartyStore`, `DATA_DIR`) that is atomic, debounced, and + flushed on SIGTERM. "Party lifecycle" above still holds for ordinary + parties. +- **"Completed" gained a second trigger.** `PartyService + .completeCurrentItem` is now the one place an item counts as done. It + handles Emby Played, as before, plus static progress. It runs from + `advanceQueue`, and from a new host-only `"itemEnded"` socket event + for the *last* queued item. That item was previously never marked + Played at all, since there was no "next" to send for it. +- **Missed = group-completed − watched in-app − Emby Played**, computed + by the pure `computeMissedIds`. Each viewer's own Emby `UserData` is + read from the source re-fetch under their own session. + ## Known limitations - Playback always goes through `hls.js` against whatever Emby's @@ -4578,7 +4606,10 @@ would have overflowed by well over 200px. A separate portrait check resume), so two listeners' `