From 1e437e4a91581a81ac116ba6a0f88dcc91c2fe1c Mon Sep 17 00:00:00 2001 From: Nicolas Date: Thu, 24 Sep 2026 19:16:55 +0200 Subject: [PATCH] Give static watch parties their own tab and one file per party - new "Parties permanentes" tab (/static, sidebar + mobile sheet), highlighted on a party's own page too; the home page no longer lists static parties, and its live "Watch parties en cours" list leaves their sessions out - StaticPartyStore now writes data/static-parties/.json - one file per party, debounced per party, so a write only touches the party that changed and a corrupt file only costs that party (moved aside to .corrupt, the rest still load); leftover .tmp files are cleaned up; deleting a party removes its file and cancels any pending save - the first release's single static-parties.json is migrated once on load, then kept as .migrated Co-Authored-By: Claude Opus 5.5 --- client/src/app.ts | 2 + client/src/shell/Shell.html | 2 + client/src/shell/Shell.ts | 10 +- client/src/views/home/Home.css | 38 --- client/src/views/home/Home.html | 5 - client/src/views/home/Home.ts | 54 +--- .../src/views/staticParties/StaticParties.css | 81 +++++ .../views/staticParties/StaticParties.html | 9 + .../src/views/staticParties/StaticParties.ts | 76 +++++ client/src/views/staticParty/StaticParty.ts | 2 +- docs/architecture.md | 14 +- server/src/services/StaticPartyService.ts | 39 +-- server/src/services/StaticPartyStore.ts | 296 +++++++++++++----- server/test/staticPartyService.test.ts | 81 ++++- spec/static-watch-party.md | 34 +- 15 files changed, 519 insertions(+), 224 deletions(-) create mode 100644 client/src/views/staticParties/StaticParties.css create mode 100644 client/src/views/staticParties/StaticParties.html create mode 100644 client/src/views/staticParties/StaticParties.ts diff --git a/client/src/app.ts b/client/src/app.ts index 442c155..35351e1 100644 --- a/client/src/app.ts +++ b/client/src/app.ts @@ -11,6 +11,7 @@ import { renderAlbum } from './views/album/Album'; import { renderPlaylist } from './views/playlist/Playlist'; import { renderPartyPlayer } from './views/player/Player'; import { renderStaticParty } from './views/staticParty/StaticParty'; +import { renderStaticParties } from './views/staticParties/StaticParties'; route('/', () => navigate('/home')); route('/login', renderLogin); @@ -21,6 +22,7 @@ route('/artist/:id', renderArtist); route('/album/:id', renderAlbum); route('/playlist/:id', renderPlaylist); route('/party/:id', renderPartyPlayer); +route('/static', renderStaticParties); route('/static/:id', renderStaticParty); /** diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index df0fa01..0e07a8b 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -3,6 +3,7 @@
Watch Party
@@ -41,6 +42,7 @@ plain scrim tap-only close used to leave unsolved). -->
Accueil + Parties permanentes
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index 60f76cb..e6ca666 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -18,6 +18,8 @@ const SHOWS_ICON_SVG = ' { appEl.innerHTML = html; appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG; + appEl.querySelector('[data-icon="static"]')!.innerHTML = STATIC_PARTIES_ICON_SVG; // Mounted once, outside #mainContent, so a listen party survives // navigating the router - see musicBar/MusicBar.ts's header comment. @@ -98,8 +101,11 @@ export async function renderShell(appEl: HTMLElement): Promise { const highlight = () => { appEl.querySelectorAll('.navItem').forEach((a) => { - const isHome = a.getAttribute('href') === '#/home'; - a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash)); + const href = a.getAttribute('href'); + const isHome = href === '#/home'; + // The static-parties tab stays highlighted on one party's own page too. + const isStaticSection = href === '#/static' && location.hash.startsWith('#/static/'); + a.classList.toggle('active', href === location.hash || (isHome && !location.hash) || isStaticSection); }); }; highlight(); diff --git a/client/src/views/home/Home.css b/client/src/views/home/Home.css index c1fd437..2b77ab6 100644 --- a/client/src/views/home/Home.css +++ b/client/src/views/home/Home.css @@ -60,41 +60,3 @@ 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 de9b9b6..a4f6f9c 100644 --- a/client/src/views/home/Home.html +++ b/client/src/views/home/Home.html @@ -7,9 +7,4 @@ - -
diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts index 3aac85f..74765aa 100644 --- a/client/src/views/home/Home.ts +++ b/client/src/views/home/Home.ts @@ -7,8 +7,7 @@ import { navigate } from '../../router'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; import { LOADING_HINT } from '../../loading'; import { showToast } from '../../toast'; -import { listStaticParties } from '../../api/staticParty'; -import type { LobbyPartyDto, StaticPartySummaryDto } from '../../types'; +import type { LobbyPartyDto } from '../../types'; export function renderHome(root: HTMLElement): () => void { root.innerHTML = html; @@ -19,20 +18,9 @@ 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. - // 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); - } - }); + // A static watch party's sessions live in their own tab + // (views/staticParties), not here - this list is classic parties only. + const lobby = connectLobby((parties) => renderParties(root, parties.filter((p) => !p.staticPartyId))); const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000); loadRecentSections(root); @@ -72,7 +60,6 @@ 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' : ''} ·