diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index 0e07a8b..8e38510 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -3,7 +3,7 @@
Watch Party
@@ -42,7 +42,7 @@ plain scrim tap-only close used to leave unsolved). -->
Accueil - Parties permanentes + Lounge parties
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index e6ca666..2a5d793 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -18,7 +18,7 @@ const SHOWS_ICON_SVG = ' = { 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 `
${drillHref ? `` : ''} @@ -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('.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; } }); } diff --git a/client/src/views/album/Album.ts b/client/src/views/album/Album.ts index 9a942cc..2b4c120 100644 --- a/client/src/views/album/Album.ts +++ b/client/src/views/album/Album.ts @@ -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"; diff --git a/client/src/views/home/Home.css b/client/src/views/home/Home.css index 2b77ab6..86d6e66 100644 --- a/client/src/views/home/Home.css +++ b/client/src/views/home/Home.css @@ -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); } diff --git a/client/src/views/home/Home.html b/client/src/views/home/Home.html index a4f6f9c..801c159 100644 --- a/client/src/views/home/Home.html +++ b/client/src/views/home/Home.html @@ -3,7 +3,7 @@
diff --git a/client/src/views/home/Home.ts b/client/src/views/home/Home.ts index 74765aa..b9eae77 100644 --- a/client/src/views/home/Home.ts +++ b/client/src/views/home/Home.ts @@ -59,6 +59,7 @@ function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void {
+ ${p.mediaType === 'music' ? 'Listen party' : 'Watch party'} ${escapeHtml(p.itemName || '')} ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} diff --git a/client/src/views/playlist/Playlist.html b/client/src/views/playlist/Playlist.html index 2e4bb22..2579623 100644 --- a/client/src/views/playlist/Playlist.html +++ b/client/src/views/playlist/Playlist.html @@ -6,7 +6,7 @@

- + Voir sur Emby
diff --git a/client/src/views/series/Series.html b/client/src/views/series/Series.html index ab5e41a..d219701 100644 --- a/client/src/views/series/Series.html +++ b/client/src/views/series/Series.html @@ -5,7 +5,7 @@

- + Voir sur Emby
diff --git a/client/src/views/staticParties/StaticParties.css b/client/src/views/staticParties/StaticParties.css index fe57ad6..3e98f3e 100644 --- a/client/src/views/staticParties/StaticParties.css +++ b/client/src/views/staticParties/StaticParties.css @@ -1,4 +1,4 @@ -/* ---- "Watch parties permanentes" tab: one card per static party ---- */ +/* ---- "Lounge parties" tab: one card per static party ---- */ .staticGrid { display: grid; diff --git a/client/src/views/staticParties/StaticParties.html b/client/src/views/staticParties/StaticParties.html index ce208ce..44b759d 100644 --- a/client/src/views/staticParties/StaticParties.html +++ b/client/src/views/staticParties/StaticParties.html @@ -1,9 +1,9 @@

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 ».

diff --git a/client/src/views/staticParties/StaticParties.ts b/client/src/views/staticParties/StaticParties.ts index bee983a..09dae4e 100644 --- a/client/src/views/staticParties/StaticParties.ts +++ b/client/src/views/staticParties/StaticParties.ts @@ -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 = '

Aucune watch party permanente pour le moment.

'; + grid.innerHTML = '

Aucune lounge party pour le moment.

'; return; } grid.innerHTML = parties.map((p) => { diff --git a/client/src/views/staticParty/StaticParty.html b/client/src/views/staticParty/StaticParty.html index 85ae4b5..4aa5cf0 100644 --- a/client/src/views/staticParty/StaticParty.html +++ b/client/src/views/staticParty/StaticParty.html @@ -2,14 +2,14 @@
- Watch party permanente + Lounge party

- +
diff --git a/client/src/views/staticParty/StaticParty.ts b/client/src/views/staticParty/StaticParty.ts index f9b3952..365b4e4 100644 --- a/client/src/views/staticParty/StaticParty.ts +++ b/client/src/views/staticParty/StaticParty.ts @@ -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 = `

${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}

`; + root.innerHTML = `

${escapeHtml((err as Error).message || 'Impossible de charger cette lounge party.')}

`; 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'); }); }); diff --git a/client/src/views/staticParty/createStaticButton.ts b/client/src/views/staticParty/createStaticButton.ts index 3e38a9b..4524f69 100644 --- a/client/src/views/staticParty/createStaticButton.ts +++ b/client/src/views/staticParty/createStaticButton.ts @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md index 2809d46..efe9d77 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/server/src/index.ts b/server/src/index.ts index 18d6666..43a3fe1 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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]!; diff --git a/spec/static-watch-party.md b/spec/static-watch-party.md index 9fb7f13..116c209 100644 --- a/spec/static-watch-party.md +++ b/spec/static-watch-party.md @@ -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