Lounge parties: own tab, one file per party, watch/listen/lounge naming #28

Merged
kyuno merged 2 commits from feat/static-parties-tab-and-per-file-storage into master 2026-09-24 19:31:55 +02:00
22 changed files with 581 additions and 252 deletions

View file

@ -11,6 +11,7 @@ import { renderAlbum } from './views/album/Album';
import { renderPlaylist } from './views/playlist/Playlist'; import { renderPlaylist } from './views/playlist/Playlist';
import { renderPartyPlayer } from './views/player/Player'; import { renderPartyPlayer } from './views/player/Player';
import { renderStaticParty } from './views/staticParty/StaticParty'; import { renderStaticParty } from './views/staticParty/StaticParty';
import { renderStaticParties } from './views/staticParties/StaticParties';
route('/', () => navigate('/home')); route('/', () => navigate('/home'));
route('/login', renderLogin); route('/login', renderLogin);
@ -21,6 +22,7 @@ route('/artist/:id', renderArtist);
route('/album/:id', renderAlbum); route('/album/:id', renderAlbum);
route('/playlist/:id', renderPlaylist); route('/playlist/:id', renderPlaylist);
route('/party/:id', renderPartyPlayer); route('/party/:id', renderPartyPlayer);
route('/static', renderStaticParties);
route('/static/:id', renderStaticParty); route('/static/:id', renderStaticParty);
/** /**

View file

@ -3,6 +3,7 @@
<div class="sidebarBrand">Watch Party</div> <div class="sidebarBrand">Watch Party</div>
<nav class="sidebarNav" id="sidebarNav"> <nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a> <a href="#/home" class="navItem">Accueil</a>
<a href="#/static" class="navItem">Lounge parties</a>
<div class="navSectionLabel">Bibliothèques</div> <div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p> <p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav> </nav>
@ -41,6 +42,7 @@
plain scrim tap-only close used to leave unsolved). --> plain scrim tap-only close used to leave unsolved). -->
<div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div> <div class="sheetGrabberHandle" id="sheetGrabberHandle"><div class="sheetGrabber"></div></div>
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a> <a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
<a href="#/static" class="navItem sheetPrimary"><span class="navIcon" data-icon="static"></span>Lounge parties</a>
<div class="navSectionLabel">Bibliothèques</div> <div class="navSectionLabel">Bibliothèques</div>
<div class="sheetGrid" id="mobileLibGrid"></div> <div class="sheetGrid" id="mobileLibGrid"></div>
<div id="mobilePlaylistsSlot"></div> <div id="mobilePlaylistsSlot"></div>

View file

@ -18,6 +18,8 @@ const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18"
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>'; const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>'; const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>'; const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
// The "Lounge parties" tab (static watch parties) - a circular "resume" arrow.
const STATIC_PARTIES_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M19.5 12a7.5 7.5 0 1 1-2.2-5.3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><path d="M19.5 4v4h-4" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M10 9l5 3-5 3Z" fill="currentColor"/></svg>';
// Emby could in principle add a CollectionType this app doesn't map yet // Emby could in principle add a CollectionType this app doesn't map yet
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES // (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
// would need updating first either way) - falls back to a plain folder // would need updating first either way) - falls back to a plain folder
@ -53,6 +55,7 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
appEl.innerHTML = html; appEl.innerHTML = html;
appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG; 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 // Mounted once, outside #mainContent, so a listen party survives
// navigating the router - see musicBar/MusicBar.ts's header comment. // navigating the router - see musicBar/MusicBar.ts's header comment.
@ -98,8 +101,11 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
const highlight = () => { const highlight = () => {
appEl.querySelectorAll('.navItem').forEach((a) => { appEl.querySelectorAll('.navItem').forEach((a) => {
const isHome = a.getAttribute('href') === '#/home'; const href = a.getAttribute('href');
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash)); 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(); highlight();

View file

@ -61,7 +61,9 @@ const DRILL_DOWN: Record<string, { route: string; label: string }> = {
function posterTileHtml(item: ItemDto): string { function posterTileHtml(item: ItemDto): string {
const drill = item.type ? DRILL_DOWN[item.type] : undefined; 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'; // Video -> "watch party", audio -> "listen party" - see the party kinds
// in docs/architecture.md (the third, "lounge party", is the static one).
const partyLabel = item.type === 'Audio' ? 'Lancer une listen party' : 'Lancer une watch party';
return ` return `
<div class="tile"> <div class="tile">
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''} ${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
@ -116,7 +118,7 @@ export function tileHtml(item: ItemDto): string {
} }
/** /**
* Wires up every "Lancer une watch party" button inside `container`, now * Wires up every "Lancer une watch/listen party" button inside `container`, now
* and for any tile added to it later. `onStart(itemId)` does the actual * and for any tile added to it later. `onStart(itemId)` does the actual
* work (create the party + navigate) - kept as a caller-supplied callback * work (create the party + navigate) - kept as a caller-supplied callback
* so this module doesn't need to depend on api/party.ts/router.ts itself. * so this module doesn't need to depend on api/party.ts/router.ts itself.
@ -132,6 +134,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn'); const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
e.preventDefault(); e.preventDefault();
const idleLabel = btn.textContent; // "watch party" or "listen party" - put back as-is on failure
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Démarrage...'; btn.textContent = 'Démarrage...';
try { try {
@ -139,7 +142,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
} catch (err) { } catch (err) {
showToast((err as Error).message, { variant: 'error' }); showToast((err as Error).message, { variant: 'error' });
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Lancer une watch party'; btn.textContent = idleLabel;
} }
}); });
} }

View file

@ -69,7 +69,7 @@ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promi
// states) - the label lives in aria-label/title for accessibility // states) - the label lives in aria-label/title for accessibility
// rather than as visible text, now that there's one per track instead // rather than as visible text, now that there's one per track instead
// of one per page (see docs/architecture.md's "Music UI rework"). // of one per page (see docs/architecture.md's "Music UI rework").
const startLabel = 'Écouter ensemble'; const startLabel = 'Lancer une listen party';
const addLabel = 'Ajouter à la file'; const addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album'; const startAlbumLabel = 'Écouter cet album';
const addAlbumLabel = "Ajouter l'album à la file"; const addAlbumLabel = "Ajouter l'album à la file";

View file

@ -61,40 +61,17 @@
font-weight: 600; font-weight: 600;
} }
/* ---- Static (persistent) watch parties ---- /* Which kind of party a card is - "Watch party" (video) or "Listen party"
A whole card is the link (to /static/:id) - reuses .partyCard's layout, (audio). Lounge parties (static) have their own tab, never this list. */
minus the default anchor styling. */ .partyKind {
.staticCard { align-self: flex-start;
color: inherit; font-size: 10px;
text-decoration: none; font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
padding: 1px 6px;
border-radius: 4px;
} }
.staticCard:hover { background: #2b252e; } .partyKind--video { color: var(--accent-bright); background: rgba(82, 181, 75, .15); }
.partyKind--music { color: #7fb4ff; background: rgba(127, 180, 255, .15); }
.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; }

View file

@ -3,13 +3,8 @@
</div> </div>
<section class="section" id="partiesSection" hidden> <section class="section" id="partiesSection" hidden>
<h2>Watch parties en cours</h2> <h2>Parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div> <div id="partiesCarousel" class="carousel"></div>
</section> </section>
<section class="section" id="staticSection" hidden>
<h2>Watch parties permanentes</h2>
<div id="staticCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div> <div id="recentSections"></div>

View file

@ -7,8 +7,7 @@ import { navigate } from '../../router';
import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles';
import { LOADING_HINT } from '../../loading'; import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast'; import { showToast } from '../../toast';
import { listStaticParties } from '../../api/staticParty'; import type { LobbyPartyDto } from '../../types';
import type { LobbyPartyDto, StaticPartySummaryDto } from '../../types';
export function renderHome(root: HTMLElement): () => void { export function renderHome(root: HTMLElement): () => void {
root.innerHTML = html; root.innerHTML = html;
@ -19,20 +18,9 @@ export function renderHome(root: HTMLElement): () => void {
// label is the one piece that ticks purely client-side (from // label is the one piece that ticks purely client-side (from
// createdUtc) - no point re-pushing from the server once a second just // createdUtc) - no point re-pushing from the server once a second just
// for a clock. // for a clock.
// Static watch parties are a REST fetch (their data changes rarely), but // A static watch party's sessions live in their own tab
// re-fetched whenever the set of live static-party sessions in the lobby // (views/staticParties), not here - this list is classic parties only.
// changes - a session starting/ending is what moves their cards' const lobby = connectLobby((parties) => renderParties(root, parties.filter((p) => !p.staticPartyId)));
// "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); const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000);
loadRecentSections(root); loadRecentSections(root);
@ -71,8 +59,8 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
<div class="partyCard"> <div class="partyCard">
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" /> <img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo"> <div class="partyCardInfo">
<span class="partyKind partyKind--${p.mediaType}">${p.mediaType === 'music' ? 'Listen party' : 'Watch party'}</span>
<strong>${escapeHtml(p.itemName || '')}</strong> <strong>${escapeHtml(p.itemName || '')}</strong>
${p.staticPartyId ? `<a class="partyCardStatic" href="#/static/${escapeHtml(p.staticPartyId)}">↻ ${escapeHtml(p.staticPartyName || '')}</a>` : ''}
<span class="hint"> <span class="hint">
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
· <span class="elapsed" · <span class="elapsed"
@ -108,39 +96,6 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
updateElapsedLabels(root); updateElapsedLabels(root);
} }
/** Fetches and renders the "Watch parties permanentes" section - hidden
* when there are none. A failure only hides the section: the rest of the
* home page doesn't depend on it. */
function loadStaticParties(root: HTMLElement): void {
listStaticParties()
.then((parties) => renderStaticParties(root, parties))
.catch((err: unknown) => {
console.error('[home] static parties failed to load', err);
const section = root.querySelector('#staticSection') as HTMLElement | null;
if (section) section.hidden = true;
});
}
/** One card per static watch party: progress, the viewer's missed count,
* and whether a session is running right now. */
function renderStaticParties(root: HTMLElement, parties: StaticPartySummaryDto[]): void {
const section = root.querySelector('#staticSection') as HTMLElement | null;
const list = root.querySelector('#staticCarousel');
if (!section || !list) return; // navigated away mid-flight
section.hidden = !parties.length;
list.innerHTML = parties.map((p) => `
<a class="partyCard staticCard" href="#/static/${escapeHtml(p.id)}">
<img src="${escapeHtml(p.imageUrl)}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.name)}</strong>
<span class="hint">${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
${p.livePartyId ? '<span class="staticCardLive">● Séance en cours</span>' : ''}
</div>
${p.missedCount ? `<span class="staticCardMissed" title="Éléments manqués">${p.missedCount} manqué${p.missedCount > 1 ? 's' : ''}</span>` : ''}
</a>
`).join('');
}
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions
/** /**

View file

@ -6,7 +6,7 @@
<p class="hint" id="playlistMeta"></p> <p class="hint" id="playlistMeta"></p>
<div class="playlistActions"> <div class="playlistActions">
<button id="launchBtn">Lancer cette playlist</button> <button id="launchBtn">Lancer cette playlist</button>
<button class="hide" id="createStaticBtn">Créer une watch party permanente</button> <button class="hide" id="createStaticBtn">Créer une lounge party</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
</div> </div>

View file

@ -5,7 +5,7 @@
<h1 id="seriesTitle"></h1> <h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p> <p class="seriesOverview hide" id="seriesOverview"></p>
<div class="seriesActions"> <div class="seriesActions">
<button id="createStaticBtn">Créer une watch party permanente</button> <button id="createStaticBtn">Créer une lounge party</button>
<a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a> <a class="linklike" id="embyLink" target="_blank" rel="noopener">Voir sur Emby</a>
</div> </div>
</div> </div>

View file

@ -0,0 +1,81 @@
/* ---- "Lounge parties" tab: one card per static party ---- */
.staticGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
/* The whole card is the link to /static/:id. */
.staticCard {
display: flex;
align-items: center;
gap: 14px;
background: var(--bg-header);
border-radius: 10px;
padding: 12px;
color: inherit;
text-decoration: none;
}
.staticCard:hover { background: #2b252e; }
.staticCard img {
width: 60px;
height: 90px;
object-fit: cover;
border-radius: 6px;
background: var(--panel);
flex-shrink: 0;
}
.staticCard img.noImage { visibility: hidden; }
.staticCardInfo {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.staticCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.staticCardInfo .hint { margin: 0; }
.staticCardNext {
font-size: 12px;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.staticCardBar {
height: 4px;
border-radius: 2px;
background: var(--panel);
overflow: hidden;
}
.staticCardBar > div { height: 100%; background: var(--accent); }
.staticCardLive {
color: var(--accent-bright);
font-size: 12px;
font-weight: 600;
}
.staticCardMissed {
flex-shrink: 0;
align-self: flex-start;
padding: 4px 10px;
border-radius: 12px;
background: var(--danger);
color: #fff;
font-size: 12px;
font-weight: 600;
}
@media (max-width: 400px) {
.staticGrid { grid-template-columns: 1fr; }
}

View file

@ -0,0 +1,9 @@
<div class="pageHeader">
<h1>Lounge parties</h1>
</div>
<p class="hint">
Une série ou une playlist regardée en groupe sur plusieurs séances : la progression du groupe
et celle de chacun sont gardées en mémoire. Pour en créer une, ouvre une série ou une playlist
vidéo et choisis « Créer une lounge party ».
</p>
<div class="staticGrid" id="staticGrid"></div>

View file

@ -0,0 +1,76 @@
import './StaticParties.css';
import html from './StaticParties.html';
import { connectLobby } from '../../api/party';
import { listStaticParties } from '../../api/staticParty';
import { escapeHtml } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { StaticPartySummaryDto } from '../../types';
/**
* Route `/static` - the "Lounge parties" tab: every static
* watch party, kept apart from the home page's classic live parties
* (whose lobby list leaves static sessions out - see Home.ts).
*
* A REST list, re-fetched whenever the set of live static-party sessions
* in the lobby changes (a session starting or ending is what moves a
* card's "séance en cours"/progress state). Not on every lobby snapshot:
* those also fire on every pause/join, and each list fetch reads Emby
* once per static party (see server/src/index.ts's syncStaticSource).
*/
export function renderStaticParties(root: HTMLElement): () => void {
root.innerHTML = html;
const grid = root.querySelector('#staticGrid') as HTMLElement;
grid.innerHTML = LOADING_HINT;
let sessionsKey: string | null = null;
const lobby = connectLobby((parties) => {
const key = parties.map((p) => p.staticPartyId).filter(Boolean).sort().join(',');
if (key === sessionsKey) return;
sessionsKey = key;
load(grid);
});
// Same leak-prevention contract as Home.ts: the router calls this before
// rendering the next view.
return function cleanup() {
lobby.disconnect();
};
}
/** Fetches and renders every static party into `grid`. */
function load(grid: HTMLElement): void {
listStaticParties()
.then((parties) => {
if (!grid.isConnected) return; // navigated away mid-flight
render(grid, parties);
})
.catch((err: unknown) => {
console.error('[staticParties] list failed to load', err);
if (grid.isConnected) grid.innerHTML = `<p class="error">${escapeHtml((err as Error).message)}</p>`;
});
}
/** One card per static party: progress, next item, the viewer's missed
* count, and whether a session is running right now. */
function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
if (!parties.length) {
grid.innerHTML = '<p class="hint">Aucune lounge party pour le moment.</p>';
return;
}
grid.innerHTML = parties.map((p) => {
const percent = p.itemCount ? Math.round((p.groupWatchedCount / p.itemCount) * 100) : 0;
return `
<a class="staticCard" href="#/static/${escapeHtml(p.id)}">
<img src="${escapeHtml(p.imageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="staticCardInfo">
<strong>${escapeHtml(p.name)}</strong>
<span class="hint">${p.sourceType === 'series' ? 'Série' : 'Playlist'} · ${p.groupWatchedCount}/${p.itemCount} vus · ${p.memberCount} membre${p.memberCount > 1 ? 's' : ''}</span>
<div class="staticCardBar"><div style="width:${percent}%"></div></div>
${p.livePartyId
? '<span class="staticCardLive">● Séance en cours</span>'
: `<span class="staticCardNext">${p.nextItemName ? `Prochain : ${escapeHtml(p.nextItemName)}` : 'Le groupe a tout vu.'}</span>`}
</div>
${p.missedCount ? `<span class="staticCardMissed" title="Éléments manqués">${p.missedCount} manqué${p.missedCount > 1 ? 's' : ''}</span>` : ''}
</a>`;
}).join('');
}

View file

@ -2,14 +2,14 @@
<div class="staticHeader"> <div class="staticHeader">
<img class="staticCover" id="staticCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" /> <img class="staticCover" id="staticCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo"> <div class="seriesInfo">
<span class="staticBadge">Watch party permanente</span> <span class="staticBadge">Lounge party</span>
<h1 id="staticTitle"></h1> <h1 id="staticTitle"></h1>
<p class="hint staticMeta" id="staticMeta"></p> <p class="hint staticMeta" id="staticMeta"></p>
<div class="staticProgress" aria-hidden="true"><div class="staticProgressFill" id="staticProgressFill"></div></div> <div class="staticProgress" aria-hidden="true"><div class="staticProgressFill" id="staticProgressFill"></div></div>
<p class="staticNext" id="staticNext"></p> <p class="staticNext" id="staticNext"></p>
<div class="staticActions"> <div class="staticActions">
<button id="sessionBtn">Lancer la séance</button> <button id="sessionBtn">Lancer la séance</button>
<button class="secondaryBtn hide" id="joinBtn">Rejoindre cette watch party</button> <button class="secondaryBtn hide" id="joinBtn">Rejoindre cette lounge party</button>
<button class="linklike hide" id="leaveBtn">Quitter</button> <button class="linklike hide" id="leaveBtn">Quitter</button>
<button class="linklike hide" id="deleteBtn">Supprimer</button> <button class="linklike hide" id="deleteBtn">Supprimer</button>
</div> </div>

View file

@ -34,7 +34,7 @@ export async function renderStaticParty(root: HTMLElement, params: RouteParams):
detail = await getStaticParty(staticId); detail = await getStaticParty(staticId);
} catch (err) { } catch (err) {
console.error('[staticParty] load failed', staticId, err); console.error('[staticParty] load failed', staticId, err);
root.innerHTML = `<p class="error">${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}</p>`; root.innerHTML = `<p class="error">${escapeHtml((err as Error).message || 'Impossible de charger cette lounge party.')}</p>`;
return; return;
} }
@ -144,14 +144,14 @@ function wireActions(root: HTMLElement, staticId: string): void {
joinBtn.addEventListener('click', () => { joinBtn.addEventListener('click', () => {
runBusy(joinBtn, async () => { runBusy(joinBtn, async () => {
await joinStaticParty(staticId); await joinStaticParty(staticId);
showToast('Tu fais maintenant partie de cette watch party.'); showToast('Tu fais maintenant partie de cette lounge party.');
await reload(root, staticId); await reload(root, staticId);
}); });
}); });
const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement; const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement;
leaveBtn.addEventListener('click', () => { leaveBtn.addEventListener('click', () => {
if (!confirm('Quitter cette watch party ? Ta progression y sera oubliée.')) return; if (!confirm('Quitter cette lounge party ? Ta progression y sera oubliée.')) return;
runBusy(leaveBtn, async () => { runBusy(leaveBtn, async () => {
await leaveStaticParty(staticId); await leaveStaticParty(staticId);
await reload(root, staticId); await reload(root, staticId);
@ -160,11 +160,11 @@ function wireActions(root: HTMLElement, staticId: string): void {
const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement; const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement;
deleteBtn.addEventListener('click', () => { deleteBtn.addEventListener('click', () => {
if (!confirm('Supprimer cette watch party pour tout le monde ? La progression de chacun sera perdue.')) return; if (!confirm('Supprimer cette lounge party pour tout le monde ? La progression de chacun sera perdue.')) return;
runBusy(deleteBtn, async () => { runBusy(deleteBtn, async () => {
await deleteStaticParty(staticId); await deleteStaticParty(staticId);
showToast('Watch party supprimée.'); showToast('Lounge party supprimée.');
navigate('/home'); navigate('/static');
}); });
}); });

View file

@ -3,7 +3,7 @@ import { navigate } from '../../router';
import { showToast } from '../../toast'; import { showToast } from '../../toast';
/** /**
* "Créer une watch party permanente" - shared by views/series and * "Créer une lounge party" - shared by views/series and
* views/playlist: creates a static watch party over `sourceId` (a series * views/playlist: creates a static watch party over `sourceId` (a series
* or a video playlist - the server decides which, and refuses anything * or a video playlist - the server decides which, and refuses anything
* else with a message) and opens its page. * else with a message) and opens its page.

View file

@ -4576,10 +4576,19 @@ short version:
so the player, sync, shared stream, lobby and host handoff are all so the player, sync, shared stream, lobby and host handoff are all
reused unchanged. `PartyService` calls into `StaticPartyService`, reused unchanged. `PartyService` calls into `StaticPartyService`,
never the other way around. never the other way around.
- **This app's first on-disk state.** A JSON file - **This app's first on-disk state.** One JSON file per static party
(`StaticPartyStore`, `DATA_DIR`) that is atomic, debounced, and (`StaticPartyStore`, `${DATA_DIR}/static-parties/<id>.json`). Writes
flushed on SIGTERM. "Party lifecycle" above still holds for ordinary are atomic, debounced per party, and flushed on SIGTERM. It started
parties. as a single file for every party; that was split, so a write only
touches the party that changed and a corrupt file costs only that
party. The old single file is migrated once, on first load. "Party
lifecycle" above still holds for ordinary parties.
- **Its own tab and its own name.** The UI calls these **Lounge
parties**, next to **Watch party** (classic video) and **Listen
party** (classic audio). The code keeps "static party". The "Lounge
parties" tab (`/static`) lists them. The home page's live "Parties en
cours" list (each card badged Watch/Listen party) leaves their
sessions out, so classic and lounge parties never share a list.
- **"Completed" gained a second trigger.** `PartyService - **"Completed" gained a second trigger.** `PartyService
.completeCurrentItem` is now the one place an item counts as done. It .completeCurrentItem` is now the one place an item counts as done. It
handles Emby Played, as before, plus static progress. It runs from handles Emby Played, as before, plus static progress. It runs from

View file

@ -258,7 +258,7 @@ app.get('/api/catalog/party-stream/:partyId/master.m3u8', allowCastCors, asyncRo
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return; return;
} }
await PartyStreamService.serveMasterPlaylist(req, res, party, session); await PartyStreamService.serveMasterPlaylist(req, res, party, session);
@ -272,7 +272,7 @@ app.get('/api/catalog/party-stream/:partyId/raw', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return; return;
} }
await PartyStreamService.serveRawAsset(req, res, party, session); await PartyStreamService.serveRawAsset(req, res, party, session);
@ -302,7 +302,7 @@ app.get('/api/catalog/burn/:partyId/master.m3u8', allowCastCors, asyncRoute(asyn
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return; return;
} }
if (!party.burnInSubtitleIndex) { if (!party.burnInSubtitleIndex) {
@ -318,7 +318,7 @@ app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (
if (!session) { res.status(401).end(); return; } if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId); const party = PartyService.getById(partyId);
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return; return;
} }
await TranscodeService.serveSegment(req, res, party, param(req, 'segment')); await TranscodeService.serveSegment(req, res, party, param(req, 'segment'));
@ -556,12 +556,12 @@ function createMusicPartyFromTracks(res: Response, session: EmbySession, tracks:
app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => { app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') { if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' }); res.status(404).json({ error: 'Listen party introuvable.' });
return; return;
} }
const session = req.embySession!; const session = req.embySession!;
if (!party.members.has(session.userId)) { if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return; return;
} }
const track = req.body || {}; const track = req.body || {};
@ -587,12 +587,12 @@ app.post('/api/parties/:partyId/tracks', requireSession, (req, res) => {
app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => { app.post('/api/parties/:partyId/queue', requireSession, asyncRoute(async (req, res) => {
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') { if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' }); res.status(404).json({ error: 'Listen party introuvable.' });
return; return;
} }
const session = req.embySession!; const session = req.embySession!;
if (!party.members.has(session.userId)) { if (!party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return; return;
} }
const itemId = req.body?.itemId; const itemId = req.body?.itemId;
@ -662,7 +662,7 @@ app.post('/api/parties/:partyId/cast-token', requireSession, (req, res) => {
const party = PartyService.getById(param(req, 'partyId')); const party = PartyService.getById(param(req, 'partyId'));
const session = req.embySession!; const session = req.embySession!;
if (!party || !party.members.has(session.userId)) { if (!party || !party.members.has(session.userId)) {
res.status(403).json({ error: "Tu n'es pas membre de cette watch party." }); res.status(403).json({ error: "Tu n'es pas membre de cette party." });
return; return;
} }
res.json({ token: CastTokenService.issue(party.id, session) }); res.json({ token: CastTokenService.issue(party.id, session) });
@ -721,7 +721,7 @@ function liveGroupPartyId(sp: StaticParty): string | null {
/** Finds `:staticId` or answers 404 - null means the response is sent. */ /** Finds `:staticId` or answers 404 - null means the response is sent. */
function staticPartyOr404(req: Request, res: Response): StaticParty | null { function staticPartyOr404(req: Request, res: Response): StaticParty | null {
const sp = StaticPartyService.getById(param(req, 'staticId')); const sp = StaticPartyService.getById(param(req, 'staticId'));
if (!sp) res.status(404).json({ error: 'Watch party permanente introuvable.' }); if (!sp) res.status(404).json({ error: 'Lounge party introuvable.' });
return sp; return sp;
} }
@ -755,7 +755,7 @@ app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
} else if (item.Type === 'Playlist') { } else if (item.Type === 'Playlist') {
source = { type: 'playlist', sourceId: item.Id }; source = { type: 'playlist', sourceId: item.Id };
} else { } else {
res.status(400).json({ error: 'Une watch party permanente se crée depuis une série ou une playlist vidéo.' }); res.status(400).json({ error: 'Une lounge party se crée depuis une série ou une playlist vidéo.' });
return; return;
} }
@ -800,7 +800,7 @@ app.delete('/api/static-parties/:staticId', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res); const sp = staticPartyOr404(req, res);
if (!sp) return; if (!sp) return;
if (!StaticPartyService.delete(sp, req.embySession!.userId)) { if (!StaticPartyService.delete(sp, req.embySession!.userId)) {
res.status(403).json({ error: 'Seule la personne qui a créé cette watch party peut la supprimer.' }); res.status(403).json({ error: 'Seule la personne qui a créé cette lounge party peut la supprimer.' });
return; return;
} }
res.json({ ok: true }); res.json({ ok: true });
@ -811,7 +811,7 @@ app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession,
if (!sp) return; if (!sp) return;
const session = req.embySession!; const session = req.embySession!;
if (!StaticPartyService.markWatched(sp, { userId: session.userId, userName: session.userName }, param(req, 'itemId'))) { if (!StaticPartyService.markWatched(sp, { userId: session.userId, userName: session.userName }, param(req, 'itemId'))) {
res.status(404).json({ error: 'Cet élément ne fait pas partie de cette watch party.' }); res.status(404).json({ error: 'Cet élément ne fait pas partie de cette lounge party.' });
return; return;
} }
res.json({ ok: true }); res.json({ ok: true });
@ -844,7 +844,7 @@ app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(asy
const queue = StaticPartyService.groupQueue(sp); const queue = StaticPartyService.groupQueue(sp);
if (!queue.length) { if (!queue.length) {
res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette watch party.' }); res.status(409).json({ error: 'Le groupe a déjà tout vu dans cette lounge party.' });
return; return;
} }
const first = queue[0]!; const first = queue[0]!;

View file

@ -70,7 +70,8 @@ export function mergeItemLists(existing: QueueItem[], fresh: QueueItem[]): { ite
* PartyService (PartyService calls into this one), keeping the dependency * PartyService (PartyService calls into this one), keeping the dependency
* one-way. * one-way.
* *
* Persisted through StaticPartyStore after every change. * Persisted through StaticPartyStore after every change - one file per
* party, so only the party that changed is ever rewritten.
*/ */
class StaticPartyServiceClass { class StaticPartyServiceClass {
/** Every static party, by id. */ /** Every static party, by id. */
@ -118,7 +119,7 @@ class StaticPartyServiceClass {
sp.members[creator.userId] = this.newMember(creator); sp.members[creator.userId] = this.newMember(creator);
this.partiesById.set(sp.id, sp); this.partiesById.set(sp.id, sp);
Logger.staticParty(`created id=${sp.id} name="${name}" source=${source.type}:${source.sourceId} items=${items.length} by=${Logger.who(creator.userId, creator.userName)}`); Logger.staticParty(`created id=${sp.id} name="${name}" source=${source.type}:${source.sourceId} items=${items.length} by=${Logger.who(creator.userId, creator.userName)}`);
this.persist(); this.persist(sp);
return sp; return sp;
} }
@ -128,13 +129,13 @@ class StaticPartyServiceClass {
if (existing) { if (existing) {
existing.userName = who.userName; existing.userName = who.userName;
existing.lastSeenUtc = Date.now(); existing.lastSeenUtc = Date.now();
this.persist(); this.persist(sp);
return existing; return existing;
} }
const member = this.newMember(who); const member = this.newMember(who);
sp.members[who.userId] = member; sp.members[who.userId] = member;
Logger.staticParty(`joined id=${sp.id} user=${Logger.who(who.userId, who.userName)} members=${Object.keys(sp.members).length}`); Logger.staticParty(`joined id=${sp.id} user=${Logger.who(who.userId, who.userName)} members=${Object.keys(sp.members).length}`);
this.persist(); this.persist(sp);
return member; return member;
} }
@ -145,7 +146,7 @@ class StaticPartyServiceClass {
if (!member) return; if (!member) return;
delete sp.members[userId]; delete sp.members[userId];
Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`); Logger.staticParty(`left id=${sp.id} user=${Logger.who(userId, member.userName)} members=${Object.keys(sp.members).length}`);
this.persist(); this.persist(sp);
} }
/** Deletes the party for good - creator only. Returns false (and does /** Deletes the party for good - creator only. Returns false (and does
@ -157,7 +158,7 @@ class StaticPartyServiceClass {
} }
this.partiesById.delete(sp.id); this.partiesById.delete(sp.id);
Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`); Logger.staticParty(`deleted id=${sp.id} name="${sp.name}"`);
this.persist(); StaticPartyStore.remove(sp.id);
return true; return true;
} }
@ -169,7 +170,7 @@ class StaticPartyServiceClass {
const changed = added > 0 || items.some((item, i) => item !== sp.items[i]); const changed = added > 0 || items.some((item, i) => item !== sp.items[i]);
sp.items = items; sp.items = items;
if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`); if (added) Logger.staticParty(`refreshed id=${sp.id} +${added} new item(s), total=${items.length}`);
if (changed) this.persist(); if (changed) this.persist(sp);
} }
/** What the group's next session plays: everything from the group's /** What the group's next session plays: everything from the group's
@ -184,7 +185,7 @@ class StaticPartyServiceClass {
if (partyId) { if (partyId) {
sp.group.lastSessionUtc = Date.now(); sp.group.lastSessionUtc = Date.now();
Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`); Logger.staticParty(`session started id=${sp.id} partyId=${partyId} at item ${sp.group.nextIndex + 1}/${sp.items.length}`);
this.persist(); this.persist(sp);
} }
} }
@ -211,7 +212,7 @@ class StaticPartyServiceClass {
sp.group.resumePositionTicks = 0; sp.group.resumePositionTicks = 0;
} }
Logger.staticParty(`item completed id=${sp.id} mode=${link.mode} item=${itemId} by=[${connected.map((c) => c.userName).join(', ')}] group=${sp.group.completedItemIds.length}/${sp.items.length}`); Logger.staticParty(`item completed id=${sp.id} mode=${link.mode} item=${itemId} by=[${connected.map((c) => c.userName).join(', ')}] group=${sp.group.completedItemIds.length}/${sp.items.length}`);
this.persist(); this.persist(sp);
} }
/** The group session's current position - saved so the next session /** The group session's current position - saved so the next session
@ -222,7 +223,7 @@ class StaticPartyServiceClass {
if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return; if (!sp || sp.items[sp.group.nextIndex]?.itemId !== itemId) return;
if (sp.group.resumePositionTicks === positionTicks) return; if (sp.group.resumePositionTicks === positionTicks) return;
sp.group.resumePositionTicks = positionTicks; sp.group.resumePositionTicks = positionTicks;
this.persist(); this.persist(sp);
} }
/** A linked live party ended - forget it as the group's live session. */ /** A linked live party ended - forget it as the group's live session. */
@ -231,7 +232,7 @@ class StaticPartyServiceClass {
if (!sp || sp.livePartyId !== partyId) return; if (!sp || sp.livePartyId !== partyId) return;
sp.livePartyId = null; sp.livePartyId = null;
Logger.staticParty(`session ended id=${sp.id} partyId=${partyId} resumeAt=item ${sp.group.nextIndex + 1} ${Math.round(sp.group.resumePositionTicks / 10000000)}s`); Logger.staticParty(`session ended id=${sp.id} partyId=${partyId} resumeAt=item ${sp.group.nextIndex + 1} ${Math.round(sp.group.resumePositionTicks / 10000000)}s`);
this.persist(); this.persist(sp);
} }
/** "Marquer comme vu" - `who` becomes a member if they weren't yet. */ /** "Marquer comme vu" - `who` becomes a member if they weren't yet. */
@ -241,7 +242,7 @@ class StaticPartyServiceClass {
if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId); if (!member.watchedItemIds.includes(itemId)) member.watchedItemIds.push(itemId);
member.lastSeenUtc = Date.now(); member.lastSeenUtc = Date.now();
Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`); Logger.staticParty(`marked watched id=${sp.id} user=${Logger.who(who.userId, who.userName)} item=${itemId}`);
this.persist(); this.persist(sp);
return true; return true;
} }
@ -291,14 +292,16 @@ class StaticPartyServiceClass {
}; };
} }
/** Writes every party to disk (debounced - see StaticPartyStore). */ /** Writes `sp`'s own file (debounced - see StaticPartyStore). Only the
private persist(): void { * party that changed is ever rewritten. */
StaticPartyStore.save(() => this.snapshot()); private persist(sp: StaticParty): void {
StaticPartyStore.save(sp.id, () => this.snapshotOf(sp));
} }
/** Every party, minus runtime-only state. */ /** `sp` minus runtime-only state. */
private snapshot(): PersistedStaticParty[] { private snapshotOf(sp: StaticParty): PersistedStaticParty {
return Array.from(this.partiesById.values()).map(({ livePartyId: _live, ...persisted }) => persisted); const { livePartyId: _live, ...persisted } = sp;
return persisted;
} }
/** When `sp` last saw anything happen - for list ordering. */ /** When `sp` last saw anything happen - for list ordering. */

View file

@ -5,121 +5,182 @@ import path from 'path';
import { Logger } from '../Logger'; import { Logger } from '../Logger';
import type { PersistedStaticParty } from '../types'; import type { PersistedStaticParty } from '../types';
/** The on-disk file's own shape - versioned so a future format change can /** One party's own file - versioned so a future format change can migrate
* migrate instead of silently misreading an older file. */ * instead of silently misreading an older file. */
interface StaticPartyFile { interface StaticPartyFile {
version: 1;
party: PersistedStaticParty;
}
/** The first release's single-file format - only read once, to migrate. */
interface LegacyStaticPartiesFile {
version: 1; version: 1;
parties: PersistedStaticParty[]; parties: PersistedStaticParty[];
} }
/** How long a save waits for more changes before writing - progress /** How long a save waits for more changes to the same party before
* updates arrive in bursts (a whole party's members at once, the 20s * writing - progress updates arrive in bursts (a whole party's members
* position sweep), no reason to rewrite the file for each one. */ * at once, the 20s position sweep). */
const SAVE_DEBOUNCE_MS = 2000; const SAVE_DEBOUNCE_MS = 2000;
/** Party ids become file names - anything else is refused outright, so a
* crafted id can never reach outside the directory. */
const SAFE_ID = /^[A-Za-z0-9-]+$/;
/** /**
* The static watch parties' persistence - one JSON file, the first and * The static watch parties' persistence: **one JSON file per party**,
* only thing this app keeps on disk (everything else is deliberately * `${DATA_DIR}/static-parties/<id>.json` - the first and only thing this
* in-memory - see docs/architecture.md's "Party lifecycle"). A JSON file * app keeps on disk (everything else is deliberately in-memory - see
* rather than a database: the data is tiny (a handful of parties, a few * docs/architecture.md's "Party lifecycle").
* hundred item ids each), the runtime image is Node 20 (no built-in
* `node:sqlite`), and a native SQLite binding would be the image's first
* compiled dependency - not worth it at this size.
* *
* Writes are atomic (write a `.tmp` sibling, then rename over the real * One file per party (rather than a single file for all of them) limits
* file) so a crash mid-write never leaves a half-written file behind, and * what any one problem can damage: a write only ever touches the party
* debounced (SAVE_DEBOUNCE_MS). A missing file is a normal first start; an * that changed, and a corrupt file costs that one party, never the rest.
* unreadable/corrupt one is logged and treated as empty - the server must * Each write is also atomic (a `.tmp` sibling, then a rename over the
* never refuse to start over it. The corrupt file is kept aside (`.corrupt`) * real file), so a crash mid-write leaves the previous version intact.
* rather than overwritten by the next save, so nothing is lost for good. *
* Writes are debounced per party (SAVE_DEBOUNCE_MS). A corrupt file is
* logged and moved aside (`.corrupt`) rather than overwritten or
* silently dropped - the server must never refuse to start over one, and
* nothing is lost for good. The first release's single
* `static-parties.json` is migrated once, on the first load that finds it.
*
* JSON files rather than a database: the data is tiny, the runtime image
* is Node 20 (no built-in `node:sqlite`), and a native SQLite binding would
* be the image's first compiled dependency - not worth it at this size.
*/ */
class StaticPartyStoreClass { class StaticPartyStoreClass {
/** Absolute path of the JSON file - see configure(). */ /** `${DATA_DIR}/static-parties` - see configure(). */
private filePath: string | null = null; private dir: string | null = null;
/** The pending debounced save, if any. */ /** The first release's single file, migrated on load if still present. */
private saveTimer: NodeJS.Timeout | null = null; private legacyFile: string | null = null;
/** What the pending save will write - always the latest snapshot. */ /** Pending debounced saves, by party id. */
private pending: (() => PersistedStaticParty[]) | null = null; private timers = new Map<string, NodeJS.Timeout>();
/** What each pending save will write - always the latest snapshot. */
private pending = new Map<string, () => PersistedStaticParty>();
/** Points the store at `dataDir/static-parties.json`. Must be called /** Points the store at `dataDir`. Must be called before anything else -
* before load()/save() - index.ts does it at startup, tests with a * index.ts does it at startup, tests with a temp directory. */
* temp directory. */
public configure(dataDir: string): void { public configure(dataDir: string): void {
this.filePath = path.resolve(dataDir, 'static-parties.json'); this.dir = path.resolve(dataDir, 'static-parties');
this.legacyFile = path.resolve(dataDir, 'static-parties.json');
} }
/** Reads every persisted party - synchronous, startup-only. */ /** Reads every persisted party - synchronous, startup-only. */
public load(): PersistedStaticParty[] { public load(): PersistedStaticParty[] {
const file = this.requirePath(); const dir = this.requireDir();
let raw: string;
try { try {
raw = fs.readFileSync(file, 'utf8'); fs.mkdirSync(dir, { recursive: true });
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') { Logger.staticParty(`could not create ${dir} - starting empty: ${(err as Error).message}`);
Logger.staticParty(`no data file yet at ${file} - starting empty`);
} else {
Logger.staticParty(`could not read ${file} - starting empty: ${(err as Error).message}`);
}
return []; return [];
} }
this.migrateLegacyFile();
let names: string[];
try { try {
const parsed = JSON.parse(raw) as Partial<StaticPartyFile>; names = fs.readdirSync(dir);
if (!parsed || !Array.isArray(parsed.parties)) throw new Error('missing "parties" array');
Logger.staticParty(`loaded ${parsed.parties.length} static party(ies) from ${file}`);
return parsed.parties;
} catch (err) { } catch (err) {
Logger.staticParty(`corrupt data file ${file} - moved aside to .corrupt, starting empty: ${(err as Error).message}`); Logger.staticParty(`could not list ${dir} - starting empty: ${(err as Error).message}`);
try {
fs.renameSync(file, `${file}.corrupt`);
} catch (renameErr) {
Logger.staticParty(`could not move corrupt file aside: ${(renameErr as Error).message}`);
}
return []; return [];
} }
}
/** Schedules a debounced write of whatever `snapshot` returns at write const parties: PersistedStaticParty[] = [];
* time (so a burst of changes only ever writes the final state). */ for (const name of names) {
public save(snapshot: () => PersistedStaticParty[]): void { const file = path.join(dir, name);
this.pending = snapshot; if (name.endsWith('.tmp')) {
if (this.saveTimer) return; // A write interrupted before its rename - the real file next to it
this.saveTimer = setTimeout(() => { // (if any) is still the last complete version.
this.saveTimer = null; this.tryUnlink(file);
this.writeNow().catch((err: unknown) => { continue;
Logger.staticParty(`save failed: ${(err as Error).message}`); }
if (!name.endsWith('.json')) continue; // .corrupt files and anything else
const party = this.readPartyFile(file);
if (party) parties.push(party);
}
Logger.staticParty(`loaded ${parties.length} static party(ies) from ${dir}`);
return parties;
}
/** Schedules a debounced write of `id`'s own file, from whatever
* `snapshot` returns at write time (so a burst of changes only ever
* writes the final state). */
public save(id: string, snapshot: () => PersistedStaticParty): void {
if (!SAFE_ID.test(id)) {
Logger.staticParty(`save refused: unsafe id "${id}"`);
return;
}
this.pending.set(id, snapshot);
if (this.timers.has(id)) return;
const timer = setTimeout(() => {
this.timers.delete(id);
this.writeOne(id).catch((err: unknown) => {
Logger.staticParty(`save failed id=${id}: ${(err as Error).message}`);
}); });
}, SAVE_DEBOUNCE_MS); }, SAVE_DEBOUNCE_MS);
this.saveTimer.unref(); // a pending save must never keep a test process (or a shutdown) alive timer.unref(); // a pending save must never keep a test process (or a shutdown) alive
this.timers.set(id, timer);
} }
/** Writes any pending save immediately and synchronously - for a /** Deletes `id`'s file - and cancels any save still pending for it, so
* graceful shutdown (SIGTERM/SIGINT), where an async write could be * a debounced write can't recreate a party that was just deleted. */
* cut short by the process exiting. */ public remove(id: string): void {
public flushSync(): void { const timer = this.timers.get(id);
if (this.saveTimer) { if (timer) clearTimeout(timer);
clearTimeout(this.saveTimer); this.timers.delete(id);
this.saveTimer = null; this.pending.delete(id);
if (!SAFE_ID.test(id)) return;
const file = this.fileFor(id);
try {
fs.unlinkSync(file);
Logger.staticParty(`removed ${file}`);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
} }
if (!this.pending) return; }
const file = this.requirePath(); }
const snapshot = this.pending;
this.pending = null; /** Writes every pending save immediately and synchronously - for a
* graceful shutdown (SIGTERM/SIGINT), where an async write could be cut
* short by the process exiting. */
public flushSync(): void {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
const pending = Array.from(this.pending.entries());
this.pending.clear();
for (const [id, snapshot] of pending) {
const file = this.fileFor(id);
try { try {
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8'); fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
fs.renameSync(`${file}.tmp`, file); fs.renameSync(`${file}.tmp`, file);
Logger.staticParty(`flushed to ${file}`);
} catch (err) { } catch (err) {
Logger.staticParty(`flush failed: ${(err as Error).message}`); Logger.staticParty(`flush failed id=${id}: ${(err as Error).message}`);
}
}
if (pending.length) Logger.staticParty(`flushed ${pending.length} static party(ies)`);
}
/** Writes every pending save now (async) - lets tests wait for disk. */
public async writeNow(): Promise<void> {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
const ids = Array.from(this.pending.keys());
try {
await Promise.all(ids.map((id) => this.writeOne(id)));
} catch (err) {
Logger.staticParty(`writeNow failed: ${(err as Error).message}`);
throw err;
} }
} }
/** Writes the pending snapshot now - atomically, via a `.tmp` rename. */ /** Writes `id`'s pending snapshot - atomically, via a `.tmp` rename. */
public async writeNow(): Promise<void> { private async writeOne(id: string): Promise<void> {
if (!this.pending) return; const snapshot = this.pending.get(id);
const file = this.requirePath(); if (!snapshot) return;
const snapshot = this.pending; this.pending.delete(id);
this.pending = null; const file = this.fileFor(id);
try { try {
await fs.promises.mkdir(path.dirname(file), { recursive: true }); await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8'); await fs.promises.writeFile(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
@ -130,16 +191,81 @@ class StaticPartyStoreClass {
} }
} }
/** The file's JSON text for `parties`. */ /** One party file's content, or null (moved aside) if it's unreadable. */
private serialize(parties: PersistedStaticParty[]): string { private readPartyFile(file: string): PersistedStaticParty | null {
const data: StaticPartyFile = { version: 1, parties }; try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as Partial<StaticPartyFile>;
const party = parsed?.party;
if (!party || typeof party.id !== 'string' || !Array.isArray(party.items)) {
throw new Error('missing or malformed "party"');
}
if (path.basename(file) !== `${party.id}.json`) throw new Error(`file name does not match id ${party.id}`);
return party;
} catch (err) {
Logger.staticParty(`corrupt file ${file} - moved aside to .corrupt, skipped: ${(err as Error).message}`);
try {
fs.renameSync(file, `${file}.corrupt`);
} catch (renameErr) {
Logger.staticParty(`could not move corrupt file aside: ${(renameErr as Error).message}`);
}
return null;
}
}
/**
* The first release kept every party in one `static-parties.json` -
* split it into one file per party, then rename it to `.migrated` (kept,
* not deleted, in case anything went wrong). A party that already has
* its own file is left alone: that file is newer.
*/
private migrateLegacyFile(): void {
const legacy = this.legacyFile!;
if (!fs.existsSync(legacy)) return;
try {
const parsed = JSON.parse(fs.readFileSync(legacy, 'utf8')) as Partial<LegacyStaticPartiesFile>;
const parties = Array.isArray(parsed?.parties) ? parsed.parties : [];
let migrated = 0;
for (const party of parties) {
if (!party || typeof party.id !== 'string' || !SAFE_ID.test(party.id)) continue;
const file = this.fileFor(party.id);
if (fs.existsSync(file)) continue;
fs.writeFileSync(`${file}.tmp`, this.serialize(party), 'utf8');
fs.renameSync(`${file}.tmp`, file);
migrated++;
}
fs.renameSync(legacy, `${legacy}.migrated`);
Logger.staticParty(`migrated ${migrated} static party(ies) from ${legacy} to one file each (original kept as .migrated)`);
} catch (err) {
Logger.staticParty(`could not migrate ${legacy} - left in place: ${(err as Error).message}`);
}
}
/** `id`'s own file path. */
private fileFor(id: string): string {
return path.join(this.requireDir(), `${id}.json`);
}
/** A party file's JSON text. */
private serialize(party: PersistedStaticParty): string {
const data: StaticPartyFile = { version: 1, party };
return JSON.stringify(data, null, 2); return JSON.stringify(data, null, 2);
} }
/** The configured file path - throws if configure() was never called. */ /** Best-effort delete - logs anything but "already gone". */
private requirePath(): string { private tryUnlink(file: string): void {
if (!this.filePath) throw new Error('StaticPartyStore.configure() was never called'); try {
return this.filePath; fs.unlinkSync(file);
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
}
}
}
/** The configured directory - throws if configure() was never called. */
private requireDir(): string {
if (!this.dir) throw new Error('StaticPartyStore.configure() was never called');
return this.dir;
} }
} }

View file

@ -22,10 +22,12 @@ before(() => {
StaticPartyStore.configure(dataDir); StaticPartyStore.configure(dataDir);
}); });
const partiesDir = () => path.join(dataDir, 'static-parties');
beforeEach(() => { beforeEach(() => {
StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test StaticPartyStore.flushSync(); // no debounced save from a previous test may land mid-test
fs.rmSync(path.join(dataDir, 'static-parties.json'), { force: true }); fs.rmSync(partiesDir(), { recursive: true, force: true });
fs.rmSync(path.join(dataDir, 'static-parties.json.corrupt'), { force: true }); for (const name of fs.readdirSync(dataDir)) fs.rmSync(path.join(dataDir, name), { recursive: true, force: true });
StaticPartyService.init(); StaticPartyService.init();
}); });
@ -166,31 +168,90 @@ describe('StaticPartyService', () => {
}); });
describe('StaticPartyStore', () => { describe('StaticPartyStore', () => {
it('round-trips every party through the JSON file, without runtime-only state', async () => { it('writes one file per party, and round-trips it without runtime-only state', async () => {
const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS); const sp = StaticPartyService.create(alice, { type: 'playlist', sourceId: 'pl1' }, 'Soirées', '/img/pl1', ITEMS);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'My Show', '/img/s1', ITEMS);
StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]); StaticPartyService.onItemCompleted({ staticPartyId: sp.id, mode: 'group' }, 'e1', [alice]);
StaticPartyService.setLiveParty(sp, 'live-1'); StaticPartyService.setLiveParty(sp, 'live-1');
await StaticPartyStore.writeNow(); await StaticPartyStore.writeNow();
const onDisk = JSON.parse(fs.readFileSync(path.join(dataDir, 'static-parties.json'), 'utf8')); assert.deepEqual(fs.readdirSync(partiesDir()).sort(), [`${sp.id}.json`, `${other.id}.json`].sort());
const onDisk = JSON.parse(fs.readFileSync(path.join(partiesDir(), `${sp.id}.json`), 'utf8'));
assert.equal(onDisk.version, 1); assert.equal(onDisk.version, 1);
assert.equal('livePartyId' in onDisk.parties[0], false); assert.equal('livePartyId' in onDisk.party, false);
StaticPartyService.init(); StaticPartyService.init();
const reloaded = StaticPartyService.getById(sp.id)!; const reloaded = StaticPartyService.getById(sp.id)!;
assert.equal(reloaded.name, 'Soirées'); assert.equal(reloaded.name, 'Soirées');
assert.deepEqual(reloaded.group.completedItemIds, ['e1']); assert.deepEqual(reloaded.group.completedItemIds, ['e1']);
assert.equal(reloaded.livePartyId, null, 'a restart ends every live session'); assert.equal(reloaded.livePartyId, null, 'a restart ends every live session');
assert.ok(StaticPartyService.getById(other.id));
}); });
it('a corrupt file is moved aside and the service starts empty instead of crashing', () => { it('a change only rewrites the party that changed', async () => {
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), '{ not json', 'utf8'); const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
const other = StaticPartyService.create(bob, { type: 'series', sourceId: 's1' }, 'B', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
const otherFile = path.join(partiesDir(), `${other.id}.json`);
const past = new Date(Date.now() - 60000);
fs.utimesSync(otherFile, past, past);
StaticPartyService.markWatched(sp, alice, 'e1');
await StaticPartyStore.writeNow();
assert.equal(fs.statSync(otherFile).mtimeMs, past.getTime(), 'the untouched party\'s file was not rewritten');
});
it('deleting a party removes its file, even with a save still pending', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
StaticPartyService.markWatched(sp, alice, 'e1'); // schedules a save...
StaticPartyService.delete(sp, alice.userId); // ...that must not recreate the file
await StaticPartyStore.writeNow();
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json`)), false);
});
it('a corrupt file is moved aside and only that party is lost', async () => {
const good = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'Good', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), 'broken-id.json'), '{ not json', 'utf8');
StaticPartyService.init(); StaticPartyService.init();
assert.equal(StaticPartyService.list().length, 0);
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.corrupt'))); assert.deepEqual(StaticPartyService.list().map((p) => p.id), [good.id]);
assert.ok(fs.existsSync(path.join(partiesDir(), 'broken-id.json.corrupt')));
}); });
it('a missing file is a normal first start', () => { it('a leftover .tmp from an interrupted write is cleaned up, the real file kept', async () => {
const sp = StaticPartyService.create(alice, { type: 'series', sourceId: 's1' }, 'A', '/img/s1', ITEMS);
await StaticPartyStore.writeNow();
fs.writeFileSync(path.join(partiesDir(), `${sp.id}.json.tmp`), '{ half-writ', 'utf8');
StaticPartyService.init();
assert.ok(StaticPartyService.getById(sp.id));
assert.equal(fs.existsSync(path.join(partiesDir(), `${sp.id}.json.tmp`)), false);
});
it('migrates the first release\'s single static-parties.json into one file per party', () => {
const legacy = { version: 1, parties: [
{ id: 'aaa-1', name: 'One', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-alice', createdUtc: 1,
items: ITEMS, group: { nextIndex: 1, resumePositionTicks: 0, completedItemIds: ['e1'], lastSessionUtc: null }, members: {} },
{ id: 'bbb-2', name: 'Two', source: { type: 'series', sourceId: 's1' }, imageUrl: '', createdByUserId: 'u-bob', createdUtc: 2,
items: ITEMS, group: { nextIndex: 0, resumePositionTicks: 0, completedItemIds: [], lastSessionUtc: null }, members: {} }
] };
fs.writeFileSync(path.join(dataDir, 'static-parties.json'), JSON.stringify(legacy), 'utf8');
StaticPartyService.init();
assert.deepEqual(StaticPartyService.list().map((p) => p.id).sort(), ['aaa-1', 'bbb-2']);
assert.deepEqual(StaticPartyService.getById('aaa-1')!.group.completedItemIds, ['e1']);
assert.ok(fs.existsSync(path.join(partiesDir(), 'aaa-1.json')));
assert.equal(fs.existsSync(path.join(dataDir, 'static-parties.json')), false);
assert.ok(fs.existsSync(path.join(dataDir, 'static-parties.json.migrated')), 'the original is kept, not deleted');
});
it('an empty data directory is a normal first start', () => {
StaticPartyService.init(); StaticPartyService.init();
assert.equal(StaticPartyService.list().length, 0); assert.equal(StaticPartyService.list().length, 0);
}); });

View file

@ -1,4 +1,16 @@
# Watch party statique (permanente) # Lounge party (watch party statique)
## Les trois types de party
| Nom dans l'interface | Quoi | Où |
|---|---|---|
| **Watch party** | party vidéo classique, éphémère | accueil, « Parties en cours » |
| **Listen party** | party audio classique, éphémère (barre musique) | accueil, « Parties en cours » |
| **Lounge party** | party statique : une série ou une playlist vidéo suivie sur plusieurs séances | onglet « Lounge parties » |
Côté code, la lounge party reste nommée « static party » (`StaticPartyService`,
route `/static`, `staticLink`...). Seuls les libellés visibles ont changé. Sur
l'accueil, chaque carte affiche un badge « Watch party » ou « Listen party ».
## Objectif ## Objectif
@ -85,14 +97,24 @@ des watch parties en cours.
## Persistance ## Persistance
- `StaticPartyStore` écrit un fichier JSON unique : - `StaticPartyStore` écrit **un fichier JSON par watch party** :
`${DATA_DIR}/static-parties.json`. `${DATA_DIR}/static-parties/<id>.json`, au format `{version: 1, party}`.
- `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker. - `DATA_DIR` vaut `./data` par défaut, et `/data` dans l'image Docker.
`docker-compose.yml` y monte `./data`. `docker-compose.yml` y monte `./data`.
- L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s). - Un fichier par party limite les dégâts : une écriture ne touche que la
Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`. party modifiée, et un fichier corrompu ne coûte que cette party-là.
- Un fichier corrompu est renommé en `.corrupt` et le serveur démarre à - L'écriture est atomique (`.tmp` puis `rename`) et debouncée (2 s) party
vide ; il ne crashe jamais pour ça. par party. Elle est flushée de façon synchrone sur `SIGTERM`/`SIGINT`.
- Au démarrage, un `.tmp` restant (écriture interrompue) est supprimé ; le
vrai fichier, s'il existe, est la dernière version complète.
- Un fichier corrompu (ou dont le nom ne correspond pas à l'id) est
renommé en `.corrupt` et ignoré ; les autres parties se chargent
normalement. Le serveur ne crashe jamais pour ça.
- Supprimer une party supprime son fichier et annule toute sauvegarde en
attente pour elle.
- Migration : l'ancien fichier unique `${DATA_DIR}/static-parties.json` de
la première version est éclaté en un fichier par party au premier
démarrage, puis renommé en `.migrated` (conservé, pas supprimé).
- Pas de SQLite : l'image tourne en Node 20 (pas de `node:sqlite`), une - Pas de SQLite : l'image tourne en Node 20 (pas de `node:sqlite`), une
dépendance native serait une première dans l'image, et les données sont dépendance native serait une première dans l'image, et les données sont
minuscules. minuscules.
@ -109,13 +131,15 @@ des watch parties en cours.
solo, ou lancer une watch party de rattrapage ; solo, ou lancer une watch party de rattrapage ;
- les membres ; - les membres ;
- la liste complète des items. - la liste complète des items.
- Bouton « Créer une watch party permanente » sur `views/series` et sur - Bouton « Créer une lounge party » sur `views/series` et sur
`views/playlist` (vidéo uniquement). `views/playlist` (vidéo uniquement).
- Accueil : - Onglet dédié « Lounge parties » (sidebar et menu mobile), route
- une section « Watch parties permanentes », avec la progression, un badge `/static` (`views/staticParties/`) : une carte par watch party avec la
« N manqués » et une pastille « séance en cours » ; progression, le prochain élément, un badge « N manqués » et une pastille
- les séances live affichent un lien `↻ nom` vers leur watch party « séance en cours ». L'onglet reste actif sur la page d'une party.
statique. - Séparation avec les watch/listen parties classiques : la liste « Parties
en cours » de l'accueil exclut les séances de lounge party
(`staticPartyId` non nul) ; elles ne s'affichent que dans leur onglet.
## Limites connues ## Limites connues