diff --git a/README.md b/README.md
index 5e8cb65..fd29e1f 100644
--- a/README.md
+++ b/README.md
@@ -12,22 +12,33 @@ including why this replaced an earlier Emby-plugin attempt (still in
1. Sign in with your Emby account (just username/password — the server
address is fixed server-side, see Deploying below).
-2. Search your Emby catalog for a title.
-3. Start a party (you become the host) or join one with a 6-character code.
-4. The host's play/pause/seek drives everyone else's player automatically.
+2. A sidebar lists your Emby libraries; the home page shows every ongoing
+ watch party plus a "recently added" row per library, Emby-style.
+3. Pick a movie/episode tile and hit "Lancer une watch party" (or drill into
+ a Series' seasons/episodes first) — you become the host — or join one
+ with a 6-character code.
+4. The host's play/pause/seek drives everyone else's player automatically;
+ starting from an episode queues the rest of the series so the host can
+ just hit "Épisode suivant" instead of restarting a party each time.
Everything — catalog, images, video, sync — flows through this app's own
backend, which proxies whatever it needs from Emby server-side. The browser
never talks to Emby directly and never holds an Emby access token; it only
ever has a session cookie for this app.
+Music isn't part of this yet — it needs its own player and a collaborative
+playlist, different enough to be its own pass. The sidebar already lists a
+Music library if you have one; the page just says so for now.
+
## Status
-Login has been exercised end to end against the real Emby server (with
-intentionally-wrong credentials, to confirm the round trip without needing
-real ones) — see [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
-for exactly what that confirms and what's still unverified beyond it
-(mainly: HLS playback once actually logged in).
+Login, and the rendering/navigation logic for the sidebar/home/library/series
+pages, have been exercised end to end (login against the real Emby server
+with intentionally-wrong credentials; the pages against mocked API
+responses). See [docs/architecture.md](docs/architecture.md#assumptions-to-verify)
+for exactly what that does and doesn't confirm — the real Emby response
+shapes this now depends on (library `CollectionType`, seasons/episodes,
+`PlaybackInfo`'s media streams) and actual HLS playback are still unverified.
## Project layout
@@ -43,9 +54,11 @@ server/ Node.js backend: proxies ALL Emby access (auth, catalog,
client/ Static frontend (plain JS, ES modules, no build step).
Talks only to server/ - never to Emby directly.
- js/embyApi.js calls our own /api/* routes (auth, search, item, stream URL)
+ js/shell.js persistent sidebar + #mainContent the router swaps
+ js/tiles.js poster-tile rendering shared by home.js and library.js
+ js/embyApi.js calls our own /api/* routes (auth, catalog, playback options)
js/party.js our own backend's party REST + WebSocket client
- js/views/ one file per screen (login, browse, item, player)
+ js/views/ one file per screen: login, home, library, series, player
src/WatchParty/ The earlier Emby-plugin attempt. Builds fine and installs,
but Emby has no supported extension point for a player
@@ -84,10 +97,11 @@ Then put it behind your existing reverse proxy like everything else
## Roadmap
- [x] Design + scaffold the standalone app
-- [x] Smoke-test locally (routing, error handling) without a real Emby server
-- [ ] Verify against the real Emby server (CORS is the main risk - see above)
-- [ ] Try a full party end-to-end with two accounts
-- [ ] TV show navigation (seasons/episodes) beyond flat search
+- [x] Proxy all Emby access server-side; Emby-themed UI (sidebar, carousels)
+- [x] TV show navigation (Series → Seasons → Episodes) + episode auto-queue
+- [x] Player language/subtitle/quality pickers
+- [ ] Try a full party end-to-end with two real accounts (still the main gap)
+- [ ] Music: dedicated player, artist/album browsing, collaborative playlist
- [ ] Tune transcoding target / support direct play when possible
No chat, no OIDC — deliberately out of scope (Emby-only auth was the
diff --git a/client/css/app.css b/client/css/app.css
index 785ec43..ab24f12 100644
--- a/client/css/app.css
+++ b/client/css/app.css
@@ -29,8 +29,8 @@ body {
#app { min-height: 100%; }
-/* Login is a small centered form - everything else (the home page) is
- full-width, see .home below. */
+/* Login is a small centered form - everything else lives inside the app
+ shell (sidebar + .mainContent), see below. */
.wrap {
max-width: 420px;
margin: 0 auto;
@@ -40,15 +40,6 @@ body {
h1 { font-size: 22px; margin: 0; }
h2 { font-size: 16px; margin: 0 0 12px; }
-.topbar {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 20px;
-}
-
.panel {
background: var(--bg-header);
border-radius: 12px;
@@ -109,30 +100,115 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
.code { color: var(--accent-bright); letter-spacing: .1em; }
-/* ---- Home page: full width, responsive ---- */
+/* ---- App shell: sidebar + routed main content ---- */
-.home {
- width: 100%;
- padding: 24px clamp(16px, 4vw, 48px) 80px;
+.appShell {
+ display: flex;
+ min-height: 100vh;
}
+.sidebar {
+ width: 220px;
+ flex-shrink: 0;
+ background: var(--bg-header);
+ padding: 20px 12px;
+ display: flex;
+ flex-direction: column;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ overflow-y: auto;
+}
+
+.sidebarBrand {
+ font-size: 18px;
+ font-weight: 700;
+ padding: 0 12px 20px;
+}
+
+.sidebarNav {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+}
+
+.navSectionLabel {
+ color: var(--muted);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ padding: 16px 12px 6px;
+}
+
+.navItem {
+ display: block;
+ padding: 10px 12px;
+ border-radius: 8px;
+ color: var(--text);
+ text-decoration: none;
+ font-size: 14px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.navItem:hover { background: rgba(255, 255, 255, .06); }
+.navItem.active { background: var(--accent); color: white; font-weight: 600; }
+
+.sidebarLogout { margin-top: 12px; padding: 8px 12px; }
+
+.mainContent {
+ flex: 1;
+ min-width: 0;
+ padding: 24px clamp(16px, 3vw, 48px) 80px;
+}
+
+/* Sidebar becomes a horizontal scrollable topbar on narrow screens - same
+ "scroll instead of wrap" treatment as .tabs below. */
+@media (max-width: 720px) {
+ .appShell { flex-direction: column; }
+ .sidebar {
+ width: 100%;
+ height: auto;
+ flex-direction: row;
+ align-items: center;
+ padding: 10px 12px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+ .sidebarBrand { padding: 0 12px 0 0; }
+ .sidebarNav { flex-direction: row; flex: none; }
+ .navSectionLabel { display: none; }
+ .sidebarLogout { margin: 0 0 0 12px; }
+}
+
+.pageHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.joinInline { display: flex; gap: 8px; align-items: center; }
+.joinInline input { width: 140px; margin: 0; }
+.joinInline button { padding: 10px 14px; }
+
.section { margin-bottom: 32px; }
-.joinSection {
+/* ---- Horizontal carousels (home page) ---- */
+
+.carousel {
display: flex;
- align-items: flex-end;
- flex-wrap: wrap;
- gap: 12px;
+ gap: 16px;
+ overflow-x: auto;
+ padding-bottom: 10px;
}
-.joinSection label { margin-bottom: 0; min-width: 160px; }
-.joinSection .error { width: 100%; }
-
-.partiesList {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
- gap: 12px;
-}
+.carousel .tile { flex: 0 0 160px; }
+.carousel .partyCard { flex: 0 0 280px; }
.partyCard {
display: flex;
@@ -162,16 +238,10 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
gap: 2px;
}
-.partyCardInfo strong {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
+.partyCardInfo strong { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }
-/* Tabs: one per Emby library. Horizontal scroll on narrow screens instead
- of wrapping, so it stays a single, obviously-swipeable row. */
+/* Tabs: one per Emby library (library.js) or per season (series.js). */
.tabs {
display: flex;
gap: 8px;
@@ -194,6 +264,8 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
.tab:hover { background: none; color: var(--text); }
.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); }
+/* ---- Tile grid (library.js) + tiles (shared with the carousels) ---- */
+
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
@@ -216,8 +288,12 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
display: block;
}
-.tile img.noImage {
- visibility: hidden;
+.tile img.noImage { visibility: hidden; }
+
+.tileLink {
+ position: absolute;
+ inset: 0 0 26px 0;
+ z-index: 1;
}
.tileLabel {
@@ -232,7 +308,8 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
see the (hover: none) block below, since :hover is unreachable there. */
.tileOverlay {
position: absolute;
- inset: 0 0 26px 0; /* stop above .tileLabel */
+ inset: 0 0 26px 0;
+ z-index: 2;
display: flex;
flex-direction: column;
align-items: stretch;
@@ -243,11 +320,13 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
border-radius: 8px;
opacity: 0;
transition: opacity .15s ease;
+ pointer-events: none;
}
.tile:hover .tileOverlay,
.tile:focus-within .tileOverlay {
opacity: 1;
+ pointer-events: auto;
}
.tileBtn {
@@ -269,6 +348,7 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
@media (hover: none) {
.tileOverlay {
opacity: 1;
+ pointer-events: auto;
inset: auto 0 26px 0;
flex-direction: row;
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
@@ -277,6 +357,53 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
.tileBtn { font-size: 11px; padding: 6px 4px; }
}
+/* ---- Series page: seasons tabs + episode list ---- */
+
+.episodeList {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.episodeRow {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ background: var(--bg-header);
+ border-radius: 10px;
+ padding: 10px;
+}
+
+.episodeRow img {
+ width: 120px;
+ aspect-ratio: 16 / 9;
+ object-fit: cover;
+ border-radius: 6px;
+ background: var(--panel);
+ flex-shrink: 0;
+}
+
+.episodeRow img.noImage { visibility: hidden; }
+
+.episodeInfo { flex: 1; min-width: 0; }
+.episodeInfo strong { display: block; margin-bottom: 4px; }
+.episodeInfo p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+}
+
+.episodeRow button { flex-shrink: 0; }
+
+@media (max-width: 600px) {
+ .episodeRow { flex-wrap: wrap; }
+ .episodeInfo { order: 3; flex-basis: 100%; }
+}
+
/* ---- Player ---- */
.playerWrap {
@@ -310,6 +437,35 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
.overlay .itemTitle { font-weight: 600; }
.overlay .members { color: var(--muted); flex: 1; }
+.hostControls {
+ position: absolute;
+ top: 64px;
+ right: 16px;
+ display: flex;
+ gap: 10px;
+ background: rgba(0, 0, 0, .6);
+ padding: 10px;
+ border-radius: 10px;
+ font-size: 12px;
+}
+
+.hostControls label {
+ color: var(--muted);
+ margin-bottom: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.hostControls select {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid #3a3a3a;
+ border-radius: 6px;
+ padding: 4px 6px;
+ font-size: 12px;
+}
+
.followerNotice {
position: absolute;
bottom: 24px;
diff --git a/client/js/app.js b/client/js/app.js
index 631c2be..3bdc63d 100644
--- a/client/js/app.js
+++ b/client/js/app.js
@@ -1,22 +1,37 @@
import { route, start, navigate } from './router.js';
import { auth, restoreSession } from './embyApi.js';
+import { renderShell } from './shell.js';
import { renderLogin } from './views/login.js';
-import { renderBrowse } from './views/browse.js';
+import { renderHome } from './views/home.js';
+import { renderLibrary } from './views/library.js';
+import { renderSeries } from './views/series.js';
import { renderPartyPlayer } from './views/player.js';
-function requireAuth(renderFn) {
- return async (root, params) => {
- if (!auth.isLoggedIn && !(await restoreSession())) {
- navigate('/login');
- return;
- }
- renderFn(root, params);
- };
+route('/', () => navigate('/home'));
+route('/login', renderLogin);
+route('/home', renderHome);
+route('/library/:id', renderLibrary);
+route('/series/:id', renderSeries);
+route('/party/:id', renderPartyPlayer);
+
+/**
+ * Renders either the bare login screen or the full shell (sidebar + routed
+ * content), depending on whether our session cookie is still valid. Exported
+ * so login.js can call it again right after a successful sign-in - there's
+ * no shell/router running yet at that point, just the login form.
+ */
+export async function boot() {
+ const appEl = document.getElementById('app');
+ const loggedIn = auth.isLoggedIn || await restoreSession();
+
+ if (!loggedIn) {
+ appEl.innerHTML = '';
+ renderLogin(appEl);
+ return;
+ }
+
+ const mainContent = await renderShell(appEl);
+ start(mainContent);
}
-route('/', () => navigate('/browse'));
-route('/login', renderLogin);
-route('/browse', requireAuth(renderBrowse));
-route('/party/:id', requireAuth(renderPartyPlayer));
-
-start(document.getElementById('app'));
+boot();
diff --git a/client/js/embyApi.js b/client/js/embyApi.js
index 63d055f..62b73f9 100644
--- a/client/js/embyApi.js
+++ b/client/js/embyApi.js
@@ -68,6 +68,29 @@ export async function getLibraryItems(libraryId) {
return data.items || [];
}
-export function streamUrl(itemId) {
- return `/api/catalog/stream/${itemId}/master.m3u8`;
+export async function getRecentItems(libraryId) {
+ const data = await apiFetch(`/api/catalog/libraries/${libraryId}/recent`);
+ return data.items || [];
+}
+
+export async function getSeriesSeasons(seriesId) {
+ const data = await apiFetch(`/api/catalog/series/${seriesId}/seasons`);
+ return data.seasons || [];
+}
+
+export async function getSeriesEpisodes(seriesId, seasonId) {
+ const data = await apiFetch(`/api/catalog/series/${seriesId}/episodes?seasonId=${encodeURIComponent(seasonId)}`);
+ return data.episodes || [];
+}
+
+export async function getPlaybackOptions(itemId) {
+ return apiFetch(`/api/catalog/item/${itemId}/playback-options`);
+}
+
+/** `options` may carry AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate. */
+export function streamUrl(itemId, options = {}) {
+ const qs = new URLSearchParams(
+ Object.fromEntries(Object.entries(options).filter(([, v]) => v !== '' && v != null))
+ ).toString();
+ return `/api/catalog/stream/${itemId}/master.m3u8${qs ? '?' + qs : ''}`;
}
diff --git a/client/js/party.js b/client/js/party.js
index a8af3d5..0367c94 100644
--- a/client/js/party.js
+++ b/client/js/party.js
@@ -42,7 +42,7 @@ export function getParty(partyId) {
* cookies to that handshake automatically for a same-origin URL) - so all
* this needs to send is which party to join.
*/
-export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose } = {}) {
+export function connectParty(partyId, { onMembers, onPlaystate, onItemChanged, onError, onClose } = {}) {
const proto = location.protocol === 'https:' ? 'wss://' : 'ws://';
const ws = new WebSocket(proto + location.host + '/ws');
@@ -55,6 +55,7 @@ export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose
try { msg = JSON.parse(evt.data); } catch (err) { return; }
if (msg.type === 'members' && onMembers) onMembers(msg.members);
if (msg.type === 'playstate' && onPlaystate) onPlaystate(msg);
+ if (msg.type === 'itemChanged' && onItemChanged) onItemChanged(msg);
if (msg.type === 'error' && onError) onError(msg.message);
});
@@ -66,6 +67,12 @@ export function connectParty(partyId, { onMembers, onPlaystate, onError, onClose
ws.send(JSON.stringify({ type: 'playstate', isPaused, positionTicks }));
}
},
+ /** Host-only: advance the party's queue (e.g. to the next episode). */
+ next() {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: 'next' }));
+ }
+ },
leave() {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'leave' }));
diff --git a/client/js/router.js b/client/js/router.js
index 02b02b7..b81d315 100644
--- a/client/js/router.js
+++ b/client/js/router.js
@@ -3,6 +3,8 @@
// (root, params) where params come from ":name" segments in the pattern.
const routes = [];
+let currentRoot = null;
+let listenerAttached = false;
export function route(pattern, handler) {
const keys = [];
@@ -23,22 +25,33 @@ export function navigate(path) {
location.hash = '#' + path;
}
-export function start(root) {
- function render() {
- const path = (location.hash.slice(1) || '/').split('?')[0];
- for (const r of routes) {
- const match = path.match(r.regex);
- if (match) {
- const params = {};
- r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
- root.innerHTML = '';
- r.handler(root, params);
- return;
- }
+function render() {
+ if (!currentRoot) return;
+ const [path, query] = (location.hash.slice(1) || '/').split('?');
+ const params = Object.fromEntries(new URLSearchParams(query || ''));
+ for (const r of routes) {
+ const match = path.match(r.regex);
+ if (match) {
+ r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1]); });
+ currentRoot.innerHTML = '';
+ r.handler(currentRoot, params);
+ return;
}
- root.innerHTML = '
Page introuvable.
';
}
+ currentRoot.innerHTML = '
Page introuvable.
';
+}
- window.addEventListener('hashchange', render);
+/**
+ * (Re)targets the router at `root` and renders the current route into it.
+ * Safe to call more than once (e.g. after a logout/login cycle re-renders
+ * the app shell) - the hashchange listener is only ever attached once, so
+ * repeated calls don't pile up duplicate listeners.
+ */
+export function start(root) {
+ currentRoot = root;
+ if (!listenerAttached) {
+ window.addEventListener('hashchange', render);
+ listenerAttached = true;
+ }
render();
}
diff --git a/client/js/shell.js b/client/js/shell.js
new file mode 100644
index 0000000..9eba509
--- /dev/null
+++ b/client/js/shell.js
@@ -0,0 +1,63 @@
+// The persistent app chrome (sidebar with collections + main content area),
+// rendered once per login. The router (router.js) only ever swaps what's
+// inside #mainContent - see app.js's boot().
+import { auth, logout, getLibraries } from './embyApi.js';
+
+const COLLECTION_ICONS = { movies: '🎬', tvshows: '📺', music: '🎵' };
+
+export async function renderShell(appEl) {
+ appEl.innerHTML = `
+
+
+
+
`;
+
+ appEl.querySelector('#logout').addEventListener('click', async () => {
+ await logout();
+ // Simplest reliable way back to a bare login screen: a real reload,
+ // rather than hand-tearing-down every shell/router listener in place.
+ location.hash = '';
+ location.reload();
+ });
+
+ const nav = appEl.querySelector('#sidebarNav');
+ const status = appEl.querySelector('#libStatus');
+ try {
+ const libraries = await getLibraries();
+ status.remove();
+ libraries.forEach((lib) => {
+ const a = document.createElement('a');
+ a.href = `#/library/${lib.id}`;
+ a.className = 'navItem';
+ a.textContent = `${COLLECTION_ICONS[lib.collectionType] || '📁'} ${lib.name}`;
+ nav.appendChild(a);
+ });
+ } catch (err) {
+ status.textContent = 'Bibliothèques indisponibles.';
+ }
+
+ const highlight = () => {
+ nav.querySelectorAll('.navItem').forEach((a) => {
+ const isHome = a.getAttribute('href') === '#/home';
+ a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
+ });
+ };
+ highlight();
+ window.addEventListener('hashchange', highlight);
+
+ return appEl.querySelector('#mainContent');
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
diff --git a/client/js/tiles.js b/client/js/tiles.js
new file mode 100644
index 0000000..c989e02
--- /dev/null
+++ b/client/js/tiles.js
@@ -0,0 +1,46 @@
+// Shared poster-tile rendering - used by the home carousels and the library
+// grid, so the two stay visually/behaviorally identical instead of drifting.
+
+export function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str;
+ return div.innerHTML;
+}
+
+export function tileHtml(item) {
+ const seriesHref = item.type === 'Series' ? `#/series/${item.id}` : null;
+ return `
+
+ 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;
+ }
+
+ // 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) => `
+
+ `).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 `