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:
parent
80c6065dfd
commit
cf82ea5c2d
5 changed files with 120 additions and 24 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue