From f563f5b0a2dffb26c6c986a07686d3cda5eb5fd2 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 13 Sep 2026 22:44:49 +0200 Subject: [PATCH] Emby-style UI: sidebar, carousels, series drill-down, episode queue, player tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Big UI pass towards looking/behaving like Emby. Deferred: music (needs its own player + a real-time collaborative playlist - different enough subsystem to be its own pass; sidebar/routing already treat it as a first-class collectionType, page just says "not yet" for now). Layout: - client/js/shell.js: persistent sidebar (one entry per Emby library, from GET /api/catalog/libraries which now also returns collectionType) + a #mainContent the router swaps on navigation. router.js made idempotent (start() safe to call more than once) so a login -> shell boot can happen twice in one page lifetime (logout then back in) without piling up duplicate hashchange listeners. - app.js's boot() renders either the bare login screen or the full shell depending on session state; login.js calls it again on success instead of navigate() (no router running yet at that point). Browsing, by collection type: - views/home.js (route /home): a carousel of every active party server-wide, a join-by-code fallback, then one "recently added" carousel per non-music library (GET /api/catalog/libraries/:id/recent, Emby's Items/Latest). - views/library.js (route /library/:id): dispatches on collectionType - movies/tvshows get the tile grid (Series tiles link into /series/:id instead of starting a party directly), music gets an honest placeholder. - views/series.js (route /series/:id): season tabs + episode list (new GET /api/catalog/series/:id/seasons and .../episodes, Emby's /Shows/{id}/Seasons and /Episodes). - tiles.js: poster-tile rendering shared by home.js and library.js so they can't drift apart. Episode queue / "next episode": - POST /api/parties now builds a queue when starting from an episode (fetches the rest of the series, from that episode onward) - partyManager.js gained queue/queueIndex/advanceQueue. - New WS message {type:"next"} (host-only) advances the queue and broadcasts {type:"itemChanged", itemId, itemName, ...}; every client reloads the player against the new item. player.js shows "Épisode suivant" when hasNext, and auto-sends next() on the video's `ended` event. Player: language/subtitles/quality: - New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's MediaStreams as audio/subtitle track lists. - master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/ MaxStreamingBitrate to PlaybackInfo; player.js has three - - -

- - - -

Chargement...

- `; - - root.querySelector('#logout').addEventListener('click', async () => { - await logout(); - navigate('/login'); - }); - - root.querySelector('#joinBtn').addEventListener('click', async () => { - const code = root.querySelector('#joinCode').value.trim(); - if (!code) return; - try { - const party = await joinParty(code); - navigate(`/party/${party.partyId}`); - } catch (err) { - root.querySelector('#joinError').textContent = err.message; - } - }); - - loadParties(root); - loadLibraries(root); -} - -async function loadParties(root) { - let parties; - try { - parties = await listActiveParties(); - } catch (err) { - return; // non-critical for the page as a whole - just skip the section - } - if (!parties.length) return; - - const section = root.querySelector('#partiesSection'); - section.classList.remove('hide'); - root.querySelector('#partiesList').innerHTML = parties.map((p) => ` -
- -
- ${escapeHtml(p.itemName || '')} - ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} -
- -
- `).join(''); - - root.querySelectorAll('.joinPartyBtn').forEach((btn) => { - btn.addEventListener('click', () => navigate(`/party/${btn.dataset.partyId}`)); - }); -} - -async function loadLibraries(root) { - const tabsEl = root.querySelector('#tabs'); - const contentEl = root.querySelector('#tabContent'); - - let libraries; - try { - libraries = await getLibraries(); - } catch (err) { - contentEl.innerHTML = '

Impossible de charger les bibliothèques Emby.

'; - return; - } - if (!libraries.length) { - contentEl.innerHTML = '

Aucune bibliothèque disponible sur ce compte.

'; - return; - } - - tabsEl.innerHTML = libraries - .map((lib, i) => ``) - .join(''); - - tabsEl.querySelectorAll('.tab').forEach((tab) => { - tab.addEventListener('click', () => { - if (tab.classList.contains('active')) return; - tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active')); - tab.classList.add('active'); - loadLibraryItems(contentEl, tab.dataset.libraryId); - }); - }); - - loadLibraryItems(contentEl, libraries[0].id); -} - -async function loadLibraryItems(contentEl, libraryId) { - contentEl.innerHTML = '

Chargement...

'; - let items; - try { - items = await getLibraryItems(libraryId); - } catch (err) { - contentEl.innerHTML = '

Impossible de charger cette bibliothèque.

'; - return; - } - if (!items.length) { - contentEl.innerHTML = '

Bibliothèque vide.

'; - return; - } - - contentEl.innerHTML = items.map((item) => ` -
- -
- ${item.canWatchParty ? `` : ''} - Voir sur Emby -
- ${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''} -
- `).join(''); - - contentEl.querySelectorAll('.startPartyBtn').forEach((btn) => { - btn.addEventListener('click', async () => { - btn.disabled = true; - btn.textContent = 'Démarrage...'; - try { - const party = await createParty(btn.dataset.itemId); - navigate(`/party/${party.partyId}`); - } catch (err) { - alert(err.message); - btn.disabled = false; - btn.textContent = 'Lancer une watch party'; - } - }); - }); -} - -function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} diff --git a/client/js/views/home.js b/client/js/views/home.js new file mode 100644 index 0000000..5399d8b --- /dev/null +++ b/client/js/views/home.js @@ -0,0 +1,98 @@ +import { getLibraries, getRecentItems } from '../embyApi.js'; +import { listActiveParties, createParty, joinParty } from '../party.js'; +import { navigate } from '../router.js'; +import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js'; + +export function renderHome(root) { + root.innerHTML = ` + +

+ + + +
`; + + root.querySelector('#joinBtn').addEventListener('click', async () => { + const code = root.querySelector('#joinCode').value.trim(); + if (!code) return; + try { + const party = await joinParty(code); + navigate(`/party/${party.partyId}`); + } catch (err) { + root.querySelector('#joinError').textContent = err.message; + } + }); + + loadParties(root); + loadRecentSections(root); +} + +async function loadParties(root) { + let parties; + try { + parties = await listActiveParties(); + } catch (err) { + return; // non-critical - the rest of the home page still works + } + if (!parties.length) return; + + root.querySelector('#partiesSection').hidden = false; + root.querySelector('#partiesCarousel').innerHTML = parties.map((p) => ` +
+ +
+ ${escapeHtml(p.itemName || '')} + ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} +
+ +
+ `).join(''); + + root.querySelectorAll('.joinPartyBtn').forEach((btn) => { + btn.addEventListener('click', () => navigate(`/party/${btn.dataset.partyId}`)); + }); +} + +async function loadRecentSections(root) { + const container = root.querySelector('#recentSections'); + let libraries; + try { + libraries = await getLibraries(); + } catch (err) { + container.innerHTML = '

Impossible de charger les bibliothèques Emby.

'; + return; + } + + for (const lib of libraries) { + if (lib.collectionType === 'music') continue; // music gets its own experience later, not a poster row + + const section = document.createElement('section'); + section.className = 'section'; + section.innerHTML = `

Ajouts récents - ${escapeHtml(lib.name)}

`; + container.appendChild(section); + + getRecentItems(lib.id) + .then((items) => { + if (!items.length) { + section.remove(); + return; + } + const carousel = section.querySelector('.carousel'); + carousel.innerHTML = items.map(tileHtml).join(''); + wireTileButtons(carousel, async (itemId) => { + const party = await createParty(itemId); + navigate(`/party/${party.partyId}`); + }); + }) + .catch(() => section.remove()); + } +} diff --git a/client/js/views/library.js b/client/js/views/library.js new file mode 100644 index 0000000..2c302de --- /dev/null +++ b/client/js/views/library.js @@ -0,0 +1,66 @@ +import { getLibraries, getLibraryItems } from '../embyApi.js'; +import { createParty } from '../party.js'; +import { navigate } from '../router.js'; +import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js'; + +/** + * Generic "one library" page, reached from the sidebar. What it renders + * depends entirely on the library's Emby CollectionType: + * - movies/tvshows (or anything else playable): a tile grid, same as the + * home page's carousels (Series tiles link into /series/:id instead of + * starting a party directly - see tiles.js). + * - music: a placeholder for now - see docs/architecture.md, deliberately + * out of scope for this pass (needs its own player + collaborative + * playlist, not just a grid). + */ +export async function renderLibrary(root, params) { + root.innerHTML = '

Chargement...

'; + + let libraries; + try { + libraries = await getLibraries(); + } catch (err) { + root.innerHTML = '

Impossible de charger les bibliothèques.

'; + return; + } + + const library = libraries.find((l) => l.id === params.id); + if (!library) { + root.innerHTML = '

Bibliothèque introuvable.

'; + return; + } + + if (library.collectionType === 'music') { + root.innerHTML = ` + +

+ La musique arrive dans une prochaine version : lecteur dédié, navigation + artiste / album, et une playlist partagée où chaque membre de la party + peut ajouter des morceaux. Pas encore disponible ici. +

`; + return; + } + + root.innerHTML = ` + +

Chargement...

`; + + const grid = root.querySelector('#grid'); + let items; + try { + items = await getLibraryItems(library.id); + } catch (err) { + grid.innerHTML = '

Impossible de charger cette bibliothèque.

'; + return; + } + if (!items.length) { + grid.innerHTML = '

Bibliothèque vide.

'; + return; + } + + grid.innerHTML = items.map(tileHtml).join(''); + wireTileButtons(grid, async (itemId) => { + const party = await createParty(itemId); + navigate(`/party/${party.partyId}`); + }); +} diff --git a/client/js/views/login.js b/client/js/views/login.js index 80df8dc..b3ce5e4 100644 --- a/client/js/views/login.js +++ b/client/js/views/login.js @@ -1,5 +1,5 @@ import { login } from '../embyApi.js'; -import { navigate } from '../router.js'; +import { boot } from '../app.js'; export function renderLogin(root) { root.innerHTML = ` @@ -25,7 +25,7 @@ export function renderLogin(root) { errorEl.textContent = ''; try { await login(form.querySelector('#username').value, form.querySelector('#password').value); - navigate('/browse'); + await boot(); // no shell/router running yet at the login screen - (re)boot into it now } catch (err) { errorEl.textContent = "Connexion impossible - vérifie ton nom d'utilisateur et ton mot de passe."; } diff --git a/client/js/views/player.js b/client/js/views/player.js index faaccd1..81b5c80 100644 --- a/client/js/views/player.js +++ b/client/js/views/player.js @@ -1,10 +1,21 @@ -import { streamUrl } from '../embyApi.js'; +import { streamUrl, getPlaybackOptions } from '../embyApi.js'; import { getParty, leaveParty, connectParty } from '../party.js'; import { navigate } from '../router.js'; +import { escapeHtml } from '../tiles.js'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns const DRIFT_TOLERANCE_SECONDS = 1.5; +// "Quality" is really "max transcoding bitrate", the same simplification +// Emby's/Jellyfin's own web clients use - not true adaptive-bitrate +// switching. See docs/architecture.md. +const QUALITY_PRESETS = [ + { label: 'Auto (qualité source)', value: '' }, + { label: '1080p (~8 Mbps)', value: '8000000' }, + { label: '720p (~4 Mbps)', value: '4000000' }, + { label: '480p (~1.5 Mbps)', value: '1500000' } +]; + export function renderPartyPlayer(root, params) { const partyId = params.id; @@ -15,8 +26,14 @@ export function renderPartyPlayer(root, params) { Code: + +
+ + + +

L'hôte contrôle la lecture.

`; @@ -24,12 +41,22 @@ export function renderPartyPlayer(root, params) { const itemTitleEl = root.querySelector('#itemTitle'); const codeEl = root.querySelector('#code'); const membersEl = root.querySelector('#members'); + const nextBtn = root.querySelector('#nextBtn'); const followerNotice = root.querySelector('#followerNotice'); + const hostControls = root.querySelector('#hostControls'); + const audioSelect = root.querySelector('#audioSelect'); + const subtitleSelect = root.querySelector('#subtitleSelect'); + const qualitySelect = root.querySelector('#qualitySelect'); + + qualitySelect.innerHTML = QUALITY_PRESETS.map((q) => ``).join(''); let isHost = false; let ws = null; + let hls = null; + let currentItemId = null; let suppressLocalEvents = false; // avoid feeding our own correction back as a new event let heartbeat = null; + let hasNextQueued = false; function applyRemoteState(msg) { suppressLocalEvents = true; @@ -47,13 +74,74 @@ export function renderPartyPlayer(root, params) { ws.sendPlaystate(video.paused, Math.round(video.currentTime * TICKS_PER_SECOND)); } + async function loadTrackOptions(itemId) { + try { + const options = await getPlaybackOptions(itemId); + audioSelect.innerHTML = options.audioTracks.length + ? options.audioTracks.map((t) => ``).join('') + : ''; + subtitleSelect.innerHTML = '' + + options.subtitleTracks.map((t) => ``).join(''); + } catch (err) { + // Non-fatal: playback still works with Emby's own defaults, just without a picker. + } + } + + function loadVideo(itemId, resumeSeconds, wasPlaying) { + currentItemId = itemId; + if (hls) { + hls.destroy(); + hls = null; + } + + const src = streamUrl(itemId, { + AudioStreamIndex: audioSelect.value, + SubtitleStreamIndex: subtitleSelect.value, + MaxStreamingBitrate: qualitySelect.value + }); + + const resume = () => { + if (resumeSeconds) video.currentTime = resumeSeconds; + if (wasPlaying) video.play().catch(() => {}); + }; + + if (window.Hls && window.Hls.isSupported()) { + hls = new window.Hls(); + hls.loadSource(src); + hls.attachMedia(video); + hls.once(window.Hls.Events.MANIFEST_PARSED, resume); + } else { + video.src = src; + video.addEventListener('loadedmetadata', resume, { once: true }); + } + } + + // Changing language/subtitles/quality means a fresh transcode (Emby bakes + // the selection in server-side - see index.js) - reload at the same + // position instead of restarting from zero. + function reloadWithCurrentOptions() { + loadVideo(currentItemId, video.currentTime, !video.paused); + } + + [audioSelect, subtitleSelect, qualitySelect].forEach((sel) => { + sel.addEventListener('change', reloadWithCurrentOptions); + }); + + async function onItemChanged(msg) { + itemTitleEl.textContent = msg.itemName || ''; + hasNextQueued = !!msg.hasNext; + nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); + await loadTrackOptions(msg.itemId); + loadVideo(msg.itemId, 0, true); + } + async function init() { let party; try { party = await getParty(partyId); } catch (err) { alert(err.message); - navigate('/browse'); + navigate('/home'); return; } @@ -61,20 +149,18 @@ export function renderPartyPlayer(root, params) { itemTitleEl.textContent = party.itemName || ''; codeEl.textContent = party.joinCode; video.controls = isHost; + hostControls.classList.toggle('hide', !isHost); followerNotice.classList.toggle('hide', isHost); + hasNextQueued = !!party.hasNext; + nextBtn.classList.toggle('hide', !(isHost && hasNextQueued)); - const src = streamUrl(party.itemId); - if (window.Hls && window.Hls.isSupported()) { - const hls = new window.Hls(); - hls.loadSource(src); - hls.attachMedia(video); - } else { - video.src = src; - } + await loadTrackOptions(party.itemId); + loadVideo(party.itemId, 0, false); ws = connectParty(partyId, { onMembers: renderMembers, onPlaystate: applyRemoteState, + onItemChanged, onError: (message) => alert(message), onClose: () => {} }); @@ -83,6 +169,7 @@ export function renderPartyPlayer(root, params) { video.addEventListener('play', sendLocalState); video.addEventListener('pause', sendLocalState); video.addEventListener('seeked', sendLocalState); + video.addEventListener('ended', () => { if (hasNextQueued) ws.next(); }); heartbeat = setInterval(sendLocalState, 3000); } } @@ -93,11 +180,14 @@ export function renderPartyPlayer(root, params) { .join(', '); } + nextBtn.addEventListener('click', () => { if (ws) ws.next(); }); + root.querySelector('#leaveBtn').addEventListener('click', async () => { if (heartbeat) clearInterval(heartbeat); + if (hls) hls.destroy(); if (ws) ws.leave(); await leaveParty(partyId).catch(() => {}); - navigate('/browse'); + navigate('/home'); }); init(); diff --git a/client/js/views/series.js b/client/js/views/series.js new file mode 100644 index 0000000..89e6ad3 --- /dev/null +++ b/client/js/views/series.js @@ -0,0 +1,93 @@ +import { getItem, getSeriesSeasons, getSeriesEpisodes } from '../embyApi.js'; +import { createParty } from '../party.js'; +import { navigate } from '../router.js'; +import { escapeHtml } from '../tiles.js'; + +export async function renderSeries(root, params) { + const seriesId = params.id; + root.innerHTML = '

Chargement...

'; + + let series; + let seasons; + try { + [series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]); + } catch (err) { + root.innerHTML = '

Impossible de charger cette série.

'; + return; + } + + root.innerHTML = ` + + + +

Chargement...

`; + + root.querySelector('#back').addEventListener('click', () => history.back()); + + if (!seasons.length) { + root.querySelector('#episodeList').innerHTML = '

Aucune saison trouvée.

'; + return; + } + + const tabsEl = root.querySelector('#seasonTabs'); + tabsEl.innerHTML = seasons + .map((s, i) => ``) + .join(''); + + tabsEl.querySelectorAll('.tab').forEach((tab) => { + tab.addEventListener('click', () => { + if (tab.classList.contains('active')) return; + tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active')); + tab.classList.add('active'); + loadEpisodes(root, seriesId, tab.dataset.seasonId); + }); + }); + + loadEpisodes(root, seriesId, seasons[0].id); +} + +async function loadEpisodes(root, seriesId, seasonId) { + const listEl = root.querySelector('#episodeList'); + listEl.innerHTML = '

Chargement...

'; + + let episodes; + try { + episodes = await getSeriesEpisodes(seriesId, seasonId); + } catch (err) { + listEl.innerHTML = '

Impossible de charger les épisodes.

'; + return; + } + if (!episodes.length) { + listEl.innerHTML = '

Aucun épisode dans cette saison.

'; + return; + } + + // Starting a party from any episode here queues the rest of the series + // from that point on server-side (see server/src/index.js) - the host can + // then use "Épisode suivant" in the player instead of restarting per episode. + listEl.innerHTML = episodes.map((ep) => ` +
+ +
+ ${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)} +

${escapeHtml(ep.overview || '')}

+
+ +
+ `).join(''); + + listEl.querySelectorAll('.startEpisodeBtn').forEach((btn) => { + btn.addEventListener('click', async () => { + btn.disabled = true; + btn.textContent = 'Démarrage...'; + try { + const party = await createParty(btn.dataset.itemId); + navigate(`/party/${party.partyId}`); + } catch (err) { + alert(err.message); + btn.disabled = false; + btn.textContent = 'Lancer une watch party'; + } + }); + }); +} diff --git a/docs/architecture.md b/docs/architecture.md index 606225b..3ce2013 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -181,47 +181,113 @@ point is confirmed: matches the long-standing convention, not confirmed against this exact build's routing. -## Home page: library tabs, tiles, ongoing parties +## UI: Emby-like shell, per-type browsing, episode queue -`client/js/views/browse.js` (the home page, route `/browse`) is now: +Reworked to look and navigate more like Emby itself, and to treat each +collection type (movies / TV / music) as its own experience rather than one +generic grid: -- **A "watch parties en cours" section** listing every active party - server-wide (`GET /api/parties` — deliberately not filtered to "your" - parties, since the point is letting people discover and join one without - needing a code passed to them separately), each with a direct "Rejoindre". -- **A join-by-code fallback** below it, for a party that isn't visible there - for whatever reason. -- **One tab per Emby library** (`GET /api/catalog/libraries`, i.e. Emby's - `/Users/{id}/Views`), each showing that library's top-level items - (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies as - movies, TV shows as Series tiles, not every episode flattened together). -- **Tiles**: poster + name, with a hover-revealed overlay carrying two - actions - "Lancer une watch party" (only for directly-playable types: - Movie/Episode/Video — a Series can't be streamed as-is, so it only gets - the second action) and "Voir sur Emby" (opens Emby's own web client to - that item in a new tab, via a `embyUrl` the backend builds from - `EMBY_SERVER_URL` + the session's `serverId`, captured at login). - `:hover`/`:focus-within` reveal the overlay on mouse/keyboard; a - `@media (hover: none)` fallback keeps it always visible (smaller, inline) - on touch devices, which have no hover state to trigger it at all. -- **Full width, no max-width cap** (`.home`), with a plain CSS grid - (`repeat(auto-fill, minmax(160px, 1fr))`) doing all the responsive - reflowing — no breakpoints needed. +- **`client/js/shell.js`** renders the persistent chrome once per login: a + sidebar (collections from `GET /api/catalog/libraries`, one icon per + `collectionType`) + a `#mainContent` element the router (`router.js`) + swaps on every navigation. `router.js` was made idempotent + (`start(root)` can be called again safely) specifically so login → shell + boot can happen more than once in a page's lifetime without piling up + duplicate `hashchange` listeners — the same class of bug the earlier Emby + plugin work ran into with its page lifecycle, worth remembering. +- **`views/home.js`** (route `/home`): a horizontal carousel of every active + party server-wide (`GET /api/parties`, deliberately not filtered to "your" + parties — the point is letting people discover and join one without a + code passed out of band) plus a join-by-code fallback, then one more + carousel per library of its recently-added items + (`GET /api/catalog/libraries/:id/recent`, Emby's own + `/Users/{id}/Items/Latest`) — skipping music (see below). +- **`views/library.js`** (route `/library/:id`): dispatches purely on the + library's `collectionType`. `movies`/`tvshows`/anything else gets a tile + grid (`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies + as movies, TV as Series tiles); `music` gets an honest "not built yet" + message instead of a half-working page (see Non-goals). +- **Tiles** (`client/js/tiles.js`, shared by the home carousels and the + library grid so they can't drift apart): poster + name, hover-revealed + overlay with "Lancer une watch party" (only for directly-playable types — + a Series tile gets "Voir les épisodes" instead, linking to `/series/:id`) + and "Voir sur Emby" (opens Emby's own web client via a `embyUrl` the + backend builds from `EMBY_SERVER_URL` + the session's `serverId`). + `:hover`/`:focus-within` reveal it on mouse/keyboard; `@media (hover: none)` + keeps it always visible on touch devices, which have no hover state at all. +- **`views/series.js`** (route `/series/:id`): season tabs + (`GET /api/catalog/series/:id/seasons`, Emby's `/Shows/{id}/Seasons`) and + an episode list per season (`GET /api/catalog/series/:id/episodes`, Emby's + `/Shows/{id}/Episodes?SeasonId=...`), each episode startable as its own + party. -Confirmed by actually driving a browser against this (see session history): -tab switching swaps grid content correctly, the hover overlay shows/hides -per-tile (not globally), a Series tile correctly omits the watch-party -button, and the touch fallback (`(hover: none)`, tested via mobile viewport -emulation) shows both buttons inline without needing hover at all. That -test used a mocked `fetch` (canned JSON standing in for `/api/parties` and -`/api/catalog/...`) since exercising it for real still needs a logged-in -session — so the rendering/interaction logic is verified, but the real Emby -response shapes for `/Views` and library items are not (see below). +### Episode queue / "next episode" + +Starting a party from an episode (`POST /api/parties`) now has the backend +fetch the rest of that series (`/Shows/{seriesId}/Episodes`, no season +filter) and store everything from the clicked episode onward as the party's +`queue` (`server/src/partyManager.js`). The host gets an "Épisode suivant" +button once `hasNext` is true; clicking it (or the video's `ended` event +firing) sends a `{type:"next"}` WebSocket message +(`server/src/wsHandler.js`), host-only, which advances `queueIndex`, +resets every member's known position, and broadcasts `{type:"itemChanged", +itemId, itemName, ...}` — every client (including the host) reloads the +player against the new `itemId` on that message. A movie or a +standalone-started episode just gets a one-item queue, so this is a strict +superset of the old single-item behavior, not a separate code path. + +### Player: language, subtitles, quality + +`GET /api/catalog/item/:id/playback-options` reads `PlaybackInfo`'s +`MediaSources[0].MediaStreams` and returns the Audio/Subtitle entries as +`{index, label}` pairs for two `