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>
<nav class="sidebarNav" id="sidebarNav">
<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>
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav>
@ -42,7 +42,7 @@
plain scrim tap-only close used to leave unsolved). -->
<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="#/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="sheetGrid" id="mobileLibGrid"></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 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>';
// 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>';
// Emby could in principle add a CollectionType this app doesn't map yet
// (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 {
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
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 `
<div class="tile">
${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
* 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.
@ -132,6 +134,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
if (!btn || btn.disabled) return;
e.preventDefault();
const idleLabel = btn.textContent; // "watch party" or "listen party" - put back as-is on failure
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
@ -139,7 +142,7 @@ export function wireTileButtons(container: ParentNode, onStart: (itemId: string)
} catch (err) {
showToast((err as Error).message, { variant: 'error' });
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
// 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").
const startLabel = 'Écouter ensemble';
const startLabel = 'Lancer une listen party';
const addLabel = 'Ajouter à la file';
const startAlbumLabel = 'Écouter cet album';
const addAlbumLabel = "Ajouter l'album à la file";

View file

@ -60,3 +60,18 @@
color: var(--accent-bright);
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>
<section class="section" id="partiesSection" hidden>
<h2>Watch parties en cours</h2>
<h2>Parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>

View file

@ -59,6 +59,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
<div class="partyCard">
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<span class="partyKind partyKind--${p.mediaType}">${p.mediaType === 'music' ? 'Listen party' : 'Watch party'}</span>
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}

View file

@ -6,7 +6,7 @@
<p class="hint" id="playlistMeta"></p>
<div class="playlistActions">
<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>
</div>
</div>

View file

@ -5,7 +5,7 @@
<h1 id="seriesTitle"></h1>
<p class="seriesOverview hide" id="seriesOverview"></p>
<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>
</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 {
display: grid;

View file

@ -1,9 +1,9 @@
<div class="pageHeader">
<h1>Watch parties permanentes</h1>
<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 watch party permanente ».
vidéo et choisis « Créer une lounge party ».
</p>
<div class="staticGrid" id="staticGrid"></div>

View file

@ -7,7 +7,7 @@ import { LOADING_HINT } from '../../loading';
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
* (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. */
function render(grid: HTMLElement, parties: StaticPartySummaryDto[]): void {
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;
}
grid.innerHTML = parties.map((p) => {

View file

@ -2,14 +2,14 @@
<div class="staticHeader">
<img class="staticCover" id="staticCover" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<span class="staticBadge">Watch party permanente</span>
<span class="staticBadge">Lounge party</span>
<h1 id="staticTitle"></h1>
<p class="hint staticMeta" id="staticMeta"></p>
<div class="staticProgress" aria-hidden="true"><div class="staticProgressFill" id="staticProgressFill"></div></div>
<p class="staticNext" id="staticNext"></p>
<div class="staticActions">
<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="deleteBtn">Supprimer</button>
</div>

View file

@ -34,7 +34,7 @@ export async function renderStaticParty(root: HTMLElement, params: RouteParams):
detail = await getStaticParty(staticId);
} catch (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;
}
@ -144,14 +144,14 @@ function wireActions(root: HTMLElement, staticId: string): void {
joinBtn.addEventListener('click', () => {
runBusy(joinBtn, async () => {
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);
});
});
const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement;
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 () => {
await leaveStaticParty(staticId);
await reload(root, staticId);
@ -160,10 +160,10 @@ function wireActions(root: HTMLElement, staticId: string): void {
const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement;
deleteBtn.addEventListener('click', () => {
if (!confirm('Supprimer cette watch party pour tout le monde ? La progression de chacun sera perdue.')) return;
if (!confirm('Supprimer cette lounge party pour tout le monde ? La progression de chacun sera perdue.')) return;
runBusy(deleteBtn, async () => {
await deleteStaticParty(staticId);
showToast('Watch party supprimée.');
showToast('Lounge party supprimée.');
navigate('/static');
});
});

View file

@ -3,7 +3,7 @@ import { navigate } from '../../router';
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
* or a video playlist - the server decides which, and refuses anything
* 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
party. The old single file is migrated once, on first load. "Party
lifecycle" above still holds for ordinary parties.
- **Its own tab.** "Parties permanentes" (`/static`) lists them. The
home page's live "Watch parties en cours" list leaves their sessions
out, so classic and permanent parties never share a list.
- **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
.completeCurrentItem` is now the one place an item counts as done. It
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; }
const party = PartyService.getById(partyId);
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;
}
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; }
const party = PartyService.getById(partyId);
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;
}
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; }
const party = PartyService.getById(partyId);
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;
}
if (!party.burnInSubtitleIndex) {
@ -318,7 +318,7 @@ app.get('/api/catalog/burn/:partyId/:segment', allowCastCors, asyncRoute(async (
if (!session) { res.status(401).end(); return; }
const party = PartyService.getById(partyId);
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;
}
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) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' });
res.status(404).json({ error: 'Listen party introuvable.' });
return;
}
const session = req.embySession!;
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;
}
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) => {
const party = PartyService.getById(param(req, 'partyId'));
if (!party || party.mediaType !== 'music') {
res.status(404).json({ error: 'Watch party musique introuvable.' });
res.status(404).json({ error: 'Listen party introuvable.' });
return;
}
const session = req.embySession!;
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;
}
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 session = req.embySession!;
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;
}
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. */
function staticPartyOr404(req: Request, res: Response): StaticParty | null {
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;
}
@ -755,7 +755,7 @@ app.post('/api/static-parties', requireSession, asyncRoute(async (req, res) => {
} else if (item.Type === 'Playlist') {
source = { type: 'playlist', sourceId: item.Id };
} 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;
}
@ -800,7 +800,7 @@ app.delete('/api/static-parties/:staticId', requireSession, (req, res) => {
const sp = staticPartyOr404(req, res);
if (!sp) return;
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;
}
res.json({ ok: true });
@ -811,7 +811,7 @@ app.post('/api/static-parties/:staticId/items/:itemId/watched', requireSession,
if (!sp) return;
const session = req.embySession!;
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;
}
res.json({ ok: true });
@ -844,7 +844,7 @@ app.post('/api/static-parties/:staticId/session', requireSession, asyncRoute(asy
const queue = StaticPartyService.groupQueue(sp);
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;
}
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
@ -119,14 +131,14 @@ des watch parties en cours.
solo, ou lancer une watch party de rattrapage ;
- les membres ;
- 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).
- 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
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éparation avec les watch parties classiques : la liste « Watch parties en
cours » de l'accueil exclut les séances de watch party permanente
- 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