Compare commits
3 commits
2de375ac62
...
db3cf68843
| Author | SHA1 | Date | |
|---|---|---|---|
| db3cf68843 | |||
| 58d51f5f74 | |||
| 1e437e4a91 |
22 changed files with 581 additions and 252 deletions
|
|
@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
81
client/src/views/staticParties/StaticParties.css
Normal file
81
client/src/views/staticParties/StaticParties.css
Normal 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; }
|
||||||
|
}
|
||||||
9
client/src/views/staticParties/StaticParties.html
Normal file
9
client/src/views/staticParties/StaticParties.html
Normal 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>
|
||||||
76
client/src/views/staticParties/StaticParties.ts
Normal file
76
client/src/views/staticParties/StaticParties.ts
Normal 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('');
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]!;
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parties: PersistedStaticParty[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const file = path.join(dir, name);
|
||||||
|
if (name.endsWith('.tmp')) {
|
||||||
|
// A write interrupted before its rename - the real file next to it
|
||||||
|
// (if any) is still the last complete version.
|
||||||
|
this.tryUnlink(file);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
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 whatever `snapshot` returns at write
|
/** Schedules a debounced write of `id`'s own file, from whatever
|
||||||
* time (so a burst of changes only ever writes the final state). */
|
* `snapshot` returns at write time (so a burst of changes only ever
|
||||||
public save(snapshot: () => PersistedStaticParty[]): void {
|
* writes the final state). */
|
||||||
this.pending = snapshot;
|
public save(id: string, snapshot: () => PersistedStaticParty): void {
|
||||||
if (this.saveTimer) return;
|
if (!SAFE_ID.test(id)) {
|
||||||
this.saveTimer = setTimeout(() => {
|
Logger.staticParty(`save refused: unsafe id "${id}"`);
|
||||||
this.saveTimer = null;
|
return;
|
||||||
this.writeNow().catch((err: unknown) => {
|
}
|
||||||
Logger.staticParty(`save failed: ${(err as Error).message}`);
|
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;
|
||||||
if (!this.pending) return;
|
const file = this.fileFor(id);
|
||||||
const file = this.requirePath();
|
|
||||||
const snapshot = this.pending;
|
|
||||||
this.pending = null;
|
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
fs.unlinkSync(file);
|
||||||
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
Logger.staticParty(`removed ${file}`);
|
||||||
fs.renameSync(`${file}.tmp`, file);
|
|
||||||
Logger.staticParty(`flushed to ${file}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.staticParty(`flush failed: ${(err as Error).message}`);
|
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
Logger.staticParty(`could not remove ${file}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Writes the pending snapshot now - atomically, via a `.tmp` rename. */
|
/** 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 {
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(`${file}.tmp`, this.serialize(snapshot()), 'utf8');
|
||||||
|
fs.renameSync(`${file}.tmp`, file);
|
||||||
|
} catch (err) {
|
||||||
|
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> {
|
public async writeNow(): Promise<void> {
|
||||||
if (!this.pending) return;
|
for (const timer of this.timers.values()) clearTimeout(timer);
|
||||||
const file = this.requirePath();
|
this.timers.clear();
|
||||||
const snapshot = this.pending;
|
const ids = Array.from(this.pending.keys());
|
||||||
this.pending = null;
|
try {
|
||||||
|
await Promise.all(ids.map((id) => this.writeOne(id)));
|
||||||
|
} catch (err) {
|
||||||
|
Logger.staticParty(`writeNow failed: ${(err as Error).message}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes `id`'s pending snapshot - atomically, via a `.tmp` rename. */
|
||||||
|
private async writeOne(id: string): Promise<void> {
|
||||||
|
const snapshot = this.pending.get(id);
|
||||||
|
if (!snapshot) return;
|
||||||
|
this.pending.delete(id);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue