Add "Lire à partir d'ici" to playlists

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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-15 20:17:08 +02:00
parent 80c6065dfd
commit cf82ea5c2d
5 changed files with 120 additions and 24 deletions

View file

@ -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 transcode can't render these as text) — one shared stream per
party, host-driven — see docs/architecture.md party, host-driven — see docs/architecture.md
- [x] Browse Emby playlists and launch one as a pre-queued party (video - [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 - [x] Sync watch-party progress back to Emby's own PlaystateService, per
member's own account, including marking an item watched once the member's own account, including marking an item watched once the
party moves past it — see docs/architecture.md party moves past it — see docs/architecture.md

View file

@ -23,8 +23,16 @@ async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T>
return res.json() as Promise<T>; return res.json() as Promise<T>;
} }
export function createParty(itemId: string): Promise<PartyPublicState> { /**
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<PartyPublicState> {
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId, startAtItemId }) });
} }
export function joinParty(partyId: string): Promise<PartyPublicState> { export function joinParty(partyId: string): Promise<PartyPublicState> {

View file

@ -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 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 * Route `/playlist/:id` - a playlist's own contents. Two actions:
* docs/architecture.md's "Scope" note: no per-item start/add here, only * "Lancer cette playlist" (always from the first item) and, per row,
* views/library's poster tiles and views/album's per-track rows do * "Lire à partir d'ici" - both launch a party pre-queued server-side
* that - a playlist item carries no back-reference to "which playlist * (see index.ts's createPartyFromPlaylist), the second just also
* contains me" the way an Episode's SeriesId does, so "start from item * passing which item to start from. That second one could only ever
* N, queue the rest of the playlist" isn't something this page can * be built here, on this exact page: unlike an Episode's own SeriesId,
* reconstruct the way views/series does). The one action here is * a playlist item carries no back-reference to "which playlist
* "Lancer cette playlist" - launches the whole thing as a party, * contains me", so resolving "item N of playlist P" needs playlist
* pre-queued server-side (see index.ts's createPartyFromPlaylist). * 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<void> { export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise<void> {
const playlistId = params.id as string; const playlistId = params.id as string;
@ -63,14 +65,23 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
<strong>${i + 1}. ${escapeHtml(item.name)}${duration ? ` <span class="hint">(${escapeHtml(duration)})</span>` : ''}</strong> <strong>${i + 1}. ${escapeHtml(item.name)}${duration ? ` <span class="hint">(${escapeHtml(duration)})</span>` : ''}</strong>
<p class="hint">${escapeHtml(meta)}</p> <p class="hint">${escapeHtml(meta)}</p>
</div> </div>
<button class="startFromBtn" data-item-id="${item.id}">Lire à partir d'ici</button>
</div>`; </div>`;
}).join(''); }).join('');
launchBtn.addEventListener('click', async () => { /**
launchBtn.disabled = true; * Shared by "Lancer cette playlist" (startAtItemId omitted - the
launchBtn.textContent = 'Démarrage...'; * 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 { try {
const party = await createParty(playlistId); const party = await createParty(playlistId, startAtItemId);
if (party.mediaType === 'music') { if (party.mediaType === 'music') {
// Same "no dedicated page for music" rule as everywhere else // Same "no dedicated page for music" rule as everywhere else
// (views/album, views/player's own dispatcher) - the persistent // (views/album, views/player's own dispatcher) - the persistent
@ -83,8 +94,14 @@ export async function renderPlaylist(root: HTMLElement, params: RouteParams): Pr
} }
} catch (err) { } catch (err) {
alert((err as Error).message); alert((err as Error).message);
launchBtn.disabled = false; btn.disabled = false;
launchBtn.textContent = 'Lancer cette playlist'; btn.textContent = idleLabel;
} }
}
launchBtn.addEventListener('click', () => launch(launchBtn, 'Démarrage...', 'Lancer cette playlist'));
listEl.querySelectorAll<HTMLButtonElement>('.startFromBtn').forEach((btn) => {
btn.addEventListener('click', () => launch(btn, 'Démarrage...', "Lire à partir d'ici", btn.dataset.itemId));
}); });
} }

View file

@ -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 isn't independently reproducible here, so this is the strongest
confirmation available without one. 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 ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's

View file

@ -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 // tracks at once) that it's its own function rather than folded into
// the single-item path below. See docs/architecture.md. // the single-item path below. See docs/architecture.md.
if (item.Type === 'Playlist') { if (item.Type === 'Playlist') {
await createPartyFromPlaylist(res, session, itemId); await createPartyFromPlaylist(res, session, itemId, req.body?.startAtItemId);
return; return;
} }
@ -359,18 +359,32 @@ function formatEpisodeName(episode: { ParentIndexNumber?: number; IndexNumber?:
* splitPlaylistByMediaType (CatalogService) decides video vs music from * splitPlaylistByMediaType (CatalogService) decides video vs music from
* the playlist's first item and drops anything that doesn't match (a * the playlist's first item and drops anything that doesn't match (a
* mixed playlist - not something this app's party model can represent). * 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<void> { async function createPartyFromPlaylist(res: Response, session: EmbySession, playlistId: string, startAtItemId?: string): Promise<void> {
const raw = await CatalogService.getPlaylistItemsRaw(session, playlistId); const raw = await CatalogService.getPlaylistItemsRaw(session, playlistId);
const { mediaType, items } = splitPlaylistByMediaType(raw); const { mediaType, items: allItems } = splitPlaylistByMediaType(raw);
if (!items.length) { if (!allItems.length) {
res.status(400).json({ error: 'Cette playlist est vide.' }); res.status(400).json({ error: 'Cette playlist est vide.' });
return; return;
} }
if (items.length !== raw.length) { if (allItems.length !== raw.length) {
Logger.party(`playlist ${playlistId} is mixed-media - keeping only the ${items.length}/${raw.length} ${mediaType} item(s)`); 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') { if (mediaType === 'music') {
const first = items[0]!; const first = items[0]!;
const party = PartyService.create( const party = PartyService.create(