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
18 changed files with 79 additions and 45 deletions
Showing only changes of commit 58d51f5f74 - Show all commits

View file

@ -3,7 +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">Parties permanentes</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>
@ -42,7 +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>Parties permanentes</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,7 +18,7 @@ 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 "Watch parties permanentes" tab - a circular "resume" arrow. // 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>'; 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

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

@ -60,3 +60,18 @@
color: var(--accent-bright); color: var(--accent-bright);
font-weight: 600; font-weight: 600;
} }
/* Which kind of party a card is - "Watch party" (video) or "Listen party"
(audio). Lounge parties (static) have their own tab, never this list. */
.partyKind {
align-self: flex-start;
font-size: 10px;
font-weight: 700;
letter-spacing: .06em;
text-transform: uppercase;
padding: 1px 6px;
border-radius: 4px;
}
.partyKind--video { color: var(--accent-bright); background: rgba(82, 181, 75, .15); }
.partyKind--music { color: #7fb4ff; background: rgba(127, 180, 255, .15); }

View file

@ -3,7 +3,7 @@
</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>

View file

@ -59,6 +59,7 @@ 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>
<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' : ''}

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

@ -1,4 +1,4 @@
/* ---- "Watch parties permanentes" tab: one card per static party ---- */ /* ---- "Lounge parties" tab: one card per static party ---- */
.staticGrid { .staticGrid {
display: grid; display: grid;

View file

@ -1,9 +1,9 @@
<div class="pageHeader"> <div class="pageHeader">
<h1>Watch parties permanentes</h1> <h1>Lounge parties</h1>
</div> </div>
<p class="hint"> <p class="hint">
Une série ou une playlist regardée en groupe sur plusieurs séances : la progression du groupe 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 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 watch party permanente ». vidéo et choisis « Créer une lounge party ».
</p> </p>
<div class="staticGrid" id="staticGrid"></div> <div class="staticGrid" id="staticGrid"></div>

View file

@ -7,7 +7,7 @@ import { LOADING_HINT } from '../../loading';
import type { StaticPartySummaryDto } from '../../types'; import type { StaticPartySummaryDto } from '../../types';
/** /**
* Route `/static` - the "Watch parties permanentes" tab: every static * Route `/static` - the "Lounge parties" tab: every static
* watch party, kept apart from the home page's classic live parties * watch party, kept apart from the home page's classic live parties
* (whose lobby list leaves static sessions out - see Home.ts). * (whose lobby list leaves static sessions out - see Home.ts).
* *
@ -54,7 +54,7 @@ function load(grid: HTMLElement): void {
* count, and whether a session is running right now. */ * count, and whether a session is running right now. */
function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void { function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
if (!parties.length) { if (!parties.length) {
grid.innerHTML = '<p class="hint">Aucune watch party permanente pour le moment.</p>'; grid.innerHTML = '<p class="hint">Aucune lounge party pour le moment.</p>';
return; return;
} }
grid.innerHTML = parties.map((p) => { grid.innerHTML = parties.map((p) => {

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,10 +160,10 @@ 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('/static'); 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

@ -4583,9 +4583,12 @@ short version:
touches the party that changed and a corrupt file costs only that touches the party that changed and a corrupt file costs only that
party. The old single file is migrated once, on first load. "Party party. The old single file is migrated once, on first load. "Party
lifecycle" above still holds for ordinary parties. lifecycle" above still holds for ordinary parties.
- **Its own tab.** "Parties permanentes" (`/static`) lists them. The - **Its own tab and its own name.** The UI calls these **Lounge
home page's live "Watch parties en cours" list leaves their sessions parties**, next to **Watch party** (classic video) and **Listen
out, so classic and permanent parties never share a list. 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

@ -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
@ -119,14 +131,14 @@ 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).
- Onglet dédié « Parties permanentes » (sidebar et menu mobile), route - Onglet dédié « Lounge parties » (sidebar et menu mobile), route
`/static` (`views/staticParties/`) : une carte par watch party avec la `/static` (`views/staticParties/`) : une carte par watch party avec la
progression, le prochain élément, un badge « N manqués » et une pastille progression, le prochain élément, un badge « N manqués » et une pastille
« séance en cours ». L'onglet reste actif sur la page d'une party. « séance en cours ». L'onglet reste actif sur la page d'une party.
- Séparation avec les watch parties classiques : la liste « Watch parties en - Séparation avec les watch/listen parties classiques : la liste « Parties
cours » de l'accueil exclut les séances de watch party permanente en cours » de l'accueil exclut les séances de lounge party
(`staticPartyId` non nul) ; elles ne s'affichent que dans leur onglet. (`staticPartyId` non nul) ; elles ne s'affichent que dans leur onglet.
## Limites connues ## Limites connues