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' `