From cf82ea5c2dfbfccd31bdd9a82a4bc86489bc37e5 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Tue, 15 Sep 2026 20:17:08 +0200 Subject: [PATCH] =?UTF-8?q?Add=20"Lire=20=C3=A0=20partir=20d'ici"=20to=20p?= =?UTF-8?q?laylists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked for directly: alongside "Lancer cette playlist" (always from the first item), a way to start a playlist party from any chosen item. views/playlist/Playlist.ts's own top comment used to rule this out, written back when the page was deliberately read-only - the stated reason was that a playlist item carries no back-reference to whichever playlist it came from, unlike an Episode's own SeriesId, so "start from item N, queue the rest" wasn't reconstructable the way views/series does it for episodes. That reasoning was correct for a bare item id on its own, but doesn't actually block this exact page: renderPlaylist already fetches the entire, ordered item list just to render it, so "item N of playlist P" only ever needed playlist P's own already-in-hand list, never a back-reference on the item itself. views/library's tiles and views/album's per-track rows still can't do this - neither has that ordered list around - which is why the feature lives only here. createParty(itemId, startAtItemId?) gained the optional second argument, threaded through POST /api/parties into createPartyFromPlaylist (index.ts) - meaningless for every other item type, so every other call site keeps working with just one argument. createPartyFromPlaylist resolves it against the playlist's own (post-splitPlaylistByMediaType) item list and slices from there, the same "slice, don't wrap" shape the single-item path already uses for SeriesId-based episode starts. Everything before the chosen item is simply left out of the party, not queued for later - true for both video and music. Each row in the playlist page gets its own "Lire à partir d'ici" button now, mirroring views/series' own per-episode button (reuses .episodeRow's existing styling/responsive wrap, no new CSS). Both it and the existing "Lancer cette playlist" button funnel through one shared launch() helper. Verified: full typecheck/test/build on both projects (118/118 server tests - no dedicated test added for createPartyFromPlaylist itself, consistent with this project's existing boundary that index.ts's route glue isn't unit-tested, only the services it calls into are). Confirmed in a real browser against the compiled bundle that the new per-row button renders correctly at both desktop and phone width. Co-Authored-By: Claude Sonnet 5 --- README.md | 4 +- client/src/api/party.ts | 12 +++++- client/src/views/playlist/Playlist.ts | 47 +++++++++++++++-------- docs/architecture.md | 55 +++++++++++++++++++++++++++ server/src/index.ts | 26 ++++++++++--- 5 files changed, 120 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1fb5a59..e983923 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,9 @@ Then put it behind your existing reverse proxy like everything else transcode can't render these as text) — one shared stream per party, host-driven — see docs/architecture.md - [x] Browse Emby playlists and launch one as a pre-queued party (video - queue or the participative music queue) — see docs/architecture.md + queue or the participative music queue), either from the start or + from any chosen item ("Lire à partir d'ici") — see + docs/architecture.md - [x] Sync watch-party progress back to Emby's own PlaystateService, per member's own account, including marking an item watched once the party moves past it — see docs/architecture.md diff --git a/client/src/api/party.ts b/client/src/api/party.ts index 4fdc47d..f2d6b86 100644 --- a/client/src/api/party.ts +++ b/client/src/api/party.ts @@ -23,8 +23,16 @@ async function apiFetch(path: string, options: RequestInit = {}): Promise return res.json() as Promise; } -export function createParty(itemId: string): Promise { - return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) }); +/** + * `startAtItemId` only ever does anything when `itemId` is a Playlist + * (see index.ts's createPartyFromPlaylist) - "Lire à partir d'ici" + * (views/playlist), picking a starting point other than the playlist's + * own first item. Meaningless (silently ignored server-side) for every + * other item type, so every other call site can keep passing just one + * argument. + */ +export function createParty(itemId: string, startAtItemId?: string): Promise { + return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId, startAtItemId }) }); } export function joinParty(partyId: string): Promise { diff --git a/client/src/views/playlist/Playlist.ts b/client/src/views/playlist/Playlist.ts index 73f85f3..8de4629 100644 --- a/client/src/views/playlist/Playlist.ts +++ b/client/src/views/playlist/Playlist.ts @@ -12,15 +12,17 @@ import type { ItemDto } from '../../types'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations /** - * Route `/playlist/:id` - a playlist's own contents, read-only (see - * docs/architecture.md's "Scope" note: no per-item start/add here, only - * views/library's poster tiles and views/album's per-track rows do - * that - a playlist item carries no back-reference to "which playlist - * contains me" the way an Episode's SeriesId does, so "start from item - * N, queue the rest of the playlist" isn't something this page can - * reconstruct the way views/series does). The one action here is - * "Lancer cette playlist" - launches the whole thing as a party, - * pre-queued server-side (see index.ts's createPartyFromPlaylist). + * Route `/playlist/:id` - a playlist's own contents. Two actions: + * "Lancer cette playlist" (always from the first item) and, per row, + * "Lire à partir d'ici" - both launch a party pre-queued server-side + * (see index.ts's createPartyFromPlaylist), the second just also + * passing which item to start from. That second one could only ever + * be built here, on this exact page: unlike an Episode's own SeriesId, + * a playlist item carries no back-reference to "which playlist + * contains me", so resolving "item N of playlist P" needs playlist + * P's own already-fetched item list in hand, not just the clicked + * item's bare id - views/library's tiles and views/album's per-track + * rows have no such list to offer, only this page does. */ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise { const playlistId = params.id as string; @@ -63,14 +65,23 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr ${i + 1}. ${escapeHtml(item.name)}${duration ? ` (${escapeHtml(duration)})` : ''}

${escapeHtml(meta)}

+ `; }).join(''); - launchBtn.addEventListener('click', async () => { - launchBtn.disabled = true; - launchBtn.textContent = 'Démarrage...'; + /** + * Shared by "Lancer cette playlist" (startAtItemId omitted - the + * server then just starts from the playlist's own first item, same + * as always) and every row's own "Lire à partir d'ici" - only the + * button clicked, its busy label, and whether a starting point is + * passed differ; everything after that (create, dispatch on + * mediaType, error handling) is identical either way. + */ + async function launch(btn: HTMLButtonElement, busyLabel: string, idleLabel: string, startAtItemId?: string) { + btn.disabled = true; + btn.textContent = busyLabel; try { - const party = await createParty(playlistId); + const party = await createParty(playlistId, startAtItemId); if (party.mediaType === 'music') { // Same "no dedicated page for music" rule as everywhere else // (views/album, views/player's own dispatcher) - the persistent @@ -83,8 +94,14 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr } } catch (err) { alert((err as Error).message); - launchBtn.disabled = false; - launchBtn.textContent = 'Lancer cette playlist'; + btn.disabled = false; + btn.textContent = idleLabel; } + } + + launchBtn.addEventListener('click', () => launch(launchBtn, 'Démarrage...', 'Lancer cette playlist')); + + listEl.querySelectorAll('.startFromBtn').forEach((btn) => { + btn.addEventListener('click', () => launch(btn, 'Démarrage...', "Lire à partir d'ici", btn.dataset.itemId)); }); } diff --git a/docs/architecture.md b/docs/architecture.md index ace0d2e..65ba2f0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2790,6 +2790,61 @@ would have latched open. A real touchscreen's own event behavior still isn't independently reproducible here, so this is the strongest confirmation available without one. +## Playlists: "Lire à partir d'ici" + +Asked for directly: alongside "Lancer cette playlist" (always from the +first item), a way to start a playlist party from any chosen item. +`views/playlist/Playlist.ts`'s own top comment used to rule this out - +written back when the page was deliberately read-only, reasoning that +"start from item N, queue the rest" wasn't reconstructable there the +way `views/series` does it for episodes, since (unlike an Episode's own +`SeriesId`) a playlist item carries no back-reference to whichever +playlist it came from. + +That reasoning was correct for a bare item id on its own - but doesn't +actually block this page specifically: `renderPlaylist` already fetches +the *entire, ordered* item list for `getPlaylistItems` to render the +list at all, so "item N of playlist P" only ever needed playlist P's +own already-in-hand list, never a back-reference on the item itself. +views/library's tiles and views/album's per-track rows still can't do +this (neither has that ordered list around) - only this exact page can, +which is why the feature lives here and nowhere else. + +`createParty(itemId, startAtItemId?)` (`client/src/api/party.ts`) gained +the optional second argument, threaded through to `POST /api/parties`'s +body and on into `createPartyFromPlaylist` (`index.ts`) - meaningless +(silently ignored) for every other item type, so every other existing +call site keeps working unchanged with just one argument. +`createPartyFromPlaylist` resolves it against the playlist's own +(post-`splitPlaylistByMediaType`) item list and slices from there - +`allItems.findIndex(...)` then `.slice(startIndex)`, the exact same +"slice, don't wrap" shape `POST /api/parties`' own single-item path +already uses for `item.SeriesId`-based episode starts, just applied to +a playlist's item list instead of a series' episode list. Everything +before the chosen item is simply left out of this party, not queued +for later - true for both video (the fixed queue is built from the +sliced list) and music (the sliced list's own first item seeds the +queue, the rest is added after it, same as launching a playlist always +worked, just from a different starting point). + +Client-side, each row in `#itemList` gets its own "Lire à partir d'ici" +button now (`.startFromBtn`, mirroring `views/series`' own +`.startEpisodeBtn` - reuses `.episodeRow`'s existing button styling/ +responsive wrap with no new CSS at all), alongside the unchanged +"Lancer cette playlist" button at the top. Both funnel through one +shared `launch()` helper - only the button, its busy/idle label text, +and whether a starting item id is passed differ; create-then-dispatch- +on-mediaType-then-error-handling is identical either way. + +Verified: full `typecheck`/`test`/`build` on both projects (118/118 +server tests - no dedicated test added for `createPartyFromPlaylist` +itself, consistent with this project's existing boundary that route +glue in `index.ts` isn't unit-tested, only the services it calls into +are). Confirmed in a real browser against the compiled bundle that the +new per-row button renders correctly alongside the existing controls, +at both desktop and phone width (the phone-width wrap is the same +`@media` rule `views/series` already relies on, untouched here). + ## Known limitations - Playback always goes through `hls.js` against whatever Emby's diff --git a/server/src/index.ts b/server/src/index.ts index bae490f..dc9f6d4 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -295,7 +295,7 @@ app.post('/api/parties', requireSession, asyncRoute(async (req, res) => { // tracks at once) that it's its own function rather than folded into // the single-item path below. See docs/architecture.md. if (item.Type === 'Playlist') { - await createPartyFromPlaylist(res, session, itemId); + await createPartyFromPlaylist(res, session, itemId, req.body?.startAtItemId); return; } @@ -359,18 +359,32 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?: * splitPlaylistByMediaType (CatalogService) decides video vs music from * the playlist's first item and drops anything that doesn't match (a * mixed playlist - not something this app's party model can represent). + * + * `startAtItemId` is views/playlist's "Lire à partir d'ici" - optional, + * and only meaningful once `items` (the playlist's own contents, already + * fetched here) is in hand: unlike an Episode's `SeriesId`, a playlist + * item carries no back-reference to whichever playlist it came from, so + * this can only ever be resolved against a specific, already-known + * playlist's own item list - never from a bare itemId alone the way + * views/series's per-episode buttons resolve straight through the + * single-item path above (`item.SeriesId`). Same slice-not-wrap + * semantics as that path too: everything before the chosen item is + * simply left out of this party, not queued for later. */ -async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string): Promise { +async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string, startAtItemId?: string): Promise { const raw = await CatalogService.getPlaylistItemsRaw(session, playlistId); - const { mediaType, items } = splitPlaylistByMediaType(raw); - if (!items.length) { + const { mediaType, items: allItems } = splitPlaylistByMediaType(raw); + if (!allItems.length) { res.status(400).json({ error: 'Cette playlist est vide.' }); return; } - if (items.length !== raw.length) { - Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${items.length}/${raw.length} ${mediaType} item(s)`); + if (allItems.length !== raw.length) { + Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${allItems.length}/${raw.length} ${mediaType} item(s)`); } + const startIndex = startAtItemId ? allItems.findIndex((i) => i.Id === startAtItemId) : -1; + const items = startIndex > 0 ? allItems.slice(startIndex) : allItems; + if (mediaType === 'music') { const first = items[0]!; const party = PartyService.create(