Emby-style UI: sidebar, carousels, series drill-down, episode queue, player tracks
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s

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 <select>s
  (host-only) that reload the stream at the same position on change.
  Subtitles end up burned into the transcode (via SubtitleStreamIndex),
  not a toggleable track - simpler than sidecar WebVTT for this pass.
  "Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.

Verified by driving a browser against mocked /api/* responses (see
session history): sidebar renders/highlights correctly, home carousels
render (parties + one row per non-music library, music correctly
skipped), library grid dispatch works, series page season-tab
switching + episode list render and the season/episode navigation
flow works end to end. Real Emby response shapes for all of this
(Views' CollectionType, Seasons/Episodes, Items/Latest, PlaybackInfo's
MediaStreams) and the queue/track-picker protocol are NOT yet
confirmed against a live server - see docs/architecture.md.

Removed client/js/views/browse.js, superseded by home.js + library.js.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 22:44:49 +02:00
parent 1891e87c83
commit f563f5b0a2
18 changed files with 1052 additions and 291 deletions

View file

@ -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

View file

@ -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;

View file

@ -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();

View file

@ -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 : ''}`;
}

View file

@ -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' }));

View file

@ -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 = '<main class="wrap"><p>Page introuvable.</p></main>';
}
currentRoot.innerHTML = '<main class="wrap"><p>Page introuvable.</p></main>';
}
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();
}

63
client/js/shell.js Normal file
View file

@ -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 = `
<div class="appShell">
<aside class="sidebar">
<div class="sidebarBrand">Watch Party</div>
<nav class="sidebarNav" id="sidebarNav">
<a href="#/home" class="navItem">Accueil</a>
<div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus">Chargement...</p>
</nav>
<button class="linklike sidebarLogout" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
</aside>
<main id="mainContent" class="mainContent"></main>
</div>`;
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;
}

46
client/js/tiles.js Normal file
View file

@ -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 `
<div class="tile">
${seriesHref ? `<a href="${seriesHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="tileOverlay">
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">Lancer une watch party</button>` : ''}
${seriesHref ? `<a class="tileBtn" href="${seriesHref}">Voir les épisodes</a>` : ''}
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
</div>`;
}
/**
* Wires up every "Lancer une watch party" button inside `container`.
* `onStart(itemId)` does the actual work (create the party + navigate) - kept
* as a caller-supplied callback so this module doesn't need to depend on
* party.js/router.js itself.
*/
export function wireTileButtons(container, onStart) {
container.querySelectorAll('.startPartyBtn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
btn.disabled = true;
btn.textContent = 'Démarrage...';
try {
await onStart(btn.dataset.itemId);
} catch (err) {
alert(err.message);
btn.disabled = false;
btn.textContent = 'Lancer une watch party';
}
});
});
}

View file

@ -1,154 +0,0 @@
import { auth, logout, getLibraries, getLibraryItems } from '../embyApi.js';
import { listActiveParties, createParty, joinParty } from '../party.js';
import { navigate } from '../router.js';
export function renderBrowse(root) {
root.innerHTML = `
<main class="home">
<div class="topbar">
<h1>Watch Party</h1>
<button class="linklike" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
</div>
<section id="partiesSection" class="section hide">
<h2>Watch parties en cours</h2>
<div id="partiesList" class="partiesList"></div>
</section>
<section class="section joinSection">
<label>Rejoindre avec un code
<input id="joinCode" maxlength="6" style="text-transform:uppercase" />
</label>
<button id="joinBtn">Rejoindre</button>
<p id="joinError" class="error"></p>
</section>
<nav id="tabs" class="tabs"></nav>
<div id="tabContent" class="grid"><p class="hint">Chargement...</p></div>
</main>`;
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) => `
<div class="partyCard">
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}</span>
</div>
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
</div>
`).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 = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
return;
}
if (!libraries.length) {
contentEl.innerHTML = '<p class="hint">Aucune bibliothèque disponible sur ce compte.</p>';
return;
}
tabsEl.innerHTML = libraries
.map((lib, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-library-id="${lib.id}">${escapeHtml(lib.name)}</button>`)
.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 = '<p class="hint">Chargement...</p>';
let items;
try {
items = await getLibraryItems(libraryId);
} catch (err) {
contentEl.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
return;
}
if (!items.length) {
contentEl.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
return;
}
contentEl.innerHTML = items.map((item) => `
<div class="tile">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="tileOverlay">
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">Lancer une watch party</button>` : ''}
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
</div>
`).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;
}

98
client/js/views/home.js Normal file
View file

@ -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 = `
<div class="pageHeader">
<h1>Accueil</h1>
<div class="joinInline">
<input id="joinCode" maxlength="6" placeholder="Code de party" style="text-transform:uppercase" />
<button id="joinBtn">Rejoindre</button>
</div>
</div>
<p id="joinError" class="error"></p>
<section class="section" id="partiesSection" hidden>
<h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div>`;
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) => `
<div class="partyCard">
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}</span>
</div>
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
</div>
`).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 = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
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 = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`;
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());
}
}

View file

@ -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 = '<p class="hint">Chargement...</p>';
let libraries;
try {
libraries = await getLibraries();
} catch (err) {
root.innerHTML = '<p class="error">Impossible de charger les bibliothèques.</p>';
return;
}
const library = libraries.find((l) => l.id === params.id);
if (!library) {
root.innerHTML = '<p class="error">Bibliothèque introuvable.</p>';
return;
}
if (library.collectionType === 'music') {
root.innerHTML = `
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
<p class="hint">
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.
</p>`;
return;
}
root.innerHTML = `
<div class="pageHeader"><h1>${escapeHtml(library.name)}</h1></div>
<div id="grid" class="grid"><p class="hint">Chargement...</p></div>`;
const grid = root.querySelector('#grid');
let items;
try {
items = await getLibraryItems(library.id);
} catch (err) {
grid.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
return;
}
if (!items.length) {
grid.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
return;
}
grid.innerHTML = items.map(tileHtml).join('');
wireTileButtons(grid, async (itemId) => {
const party = await createParty(itemId);
navigate(`/party/${party.partyId}`);
});
}

View file

@ -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.";
}

View file

@ -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) {
<span id="itemTitle" class="itemTitle"></span>
<span>Code: <span id="code" class="code"></span></span>
<span id="members" class="members"></span>
<button id="nextBtn" class="linklike hide">Épisode suivant &rarr;</button>
<button id="leaveBtn" class="linklike">Quitter</button>
</div>
<div id="hostControls" class="hostControls hide">
<label>Langue <select id="audioSelect"></select></label>
<label>Sous-titres <select id="subtitleSelect"><option value="">Aucun</option></select></label>
<label>Qualité <select id="qualitySelect"></select></label>
</div>
<p id="followerNotice" class="followerNotice hide">L'hôte contrôle la lecture.</p>
</div>`;
@ -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) => `<option value="${q.value}">${q.label}</option>`).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) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).join('')
: '<option value="">Piste par défaut</option>';
subtitleSelect.innerHTML = '<option value="">Aucun</option>' +
options.subtitleTracks.map((t) => `<option value="${t.index}">${escapeHtml(t.label)}</option>`).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();

93
client/js/views/series.js Normal file
View file

@ -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 = '<p class="hint">Chargement...</p>';
let series;
let seasons;
try {
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
} catch (err) {
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
return;
}
root.innerHTML = `
<button class="linklike" id="back">&larr; Retour</button>
<div class="pageHeader"><h1>${escapeHtml(series.name)}</h1></div>
<nav id="seasonTabs" class="tabs"></nav>
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>`;
root.querySelector('#back').addEventListener('click', () => history.back());
if (!seasons.length) {
root.querySelector('#episodeList').innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
return;
}
const tabsEl = root.querySelector('#seasonTabs');
tabsEl.innerHTML = seasons
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
.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 = '<p class="hint">Chargement...</p>';
let episodes;
try {
episodes = await getSeriesEpisodes(seriesId, seasonId);
} catch (err) {
listEl.innerHTML = '<p class="error">Impossible de charger les épisodes.</p>';
return;
}
if (!episodes.length) {
listEl.innerHTML = '<p class="hint">Aucun épisode dans cette saison.</p>';
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) => `
<div class="episodeRow">
<img src="${ep.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo">
<strong>${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)}</strong>
<p class="hint">${escapeHtml(ep.overview || '')}</p>
</div>
<button class="startEpisodeBtn" data-item-id="${ep.id}">Lancer une watch party</button>
</div>
`).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';
}
});
});
}

View file

@ -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 `<select>`s in the player's host-only
controls. Picking a value reloads the stream
(`/api/catalog/stream/:id/master.m3u8?AudioStreamIndex=&SubtitleStreamIndex=&MaxStreamingBitrate=`)
at the same position (`hls.js` destroyed and rebuilt, `currentTime`/paused
state preserved across the reload) rather than restarting from zero.
"Quality" is a fixed set of `MaxStreamingBitrate` presets (Auto/1080p/720p/
480p) forwarded to `PlaybackInfo` — the same simplification Emby's/
Jellyfin's own web clients use; it's a transcode ceiling, not true
client-side adaptive bitrate switching. Subtitles picked this way get
burned into the transcoded video (Emby's doing, via `SubtitleStreamIndex`)
rather than rendered as a toggleable `<track>` — simpler to implement
correctly than sidecar WebVTT, at the cost of a re-transcode to turn them
on/off.
### Confirmed vs. not, for all of the above
Driving an actual browser against this (see session history) confirms the
**rendering and interaction logic**: sidebar highlighting, home carousels
(parties + one row per non-music library), library grid dispatch, and the
series page's season-tab switching + episode list all render and navigate
correctly. That test mocked every `/api/...` response (canned JSON), since
exercising it for real needs a logged-in session — so what's **not**
confirmed yet is the shape of the real Emby responses this now depends on:
`Views` items actually carrying a `CollectionType` field the way assumed,
`/Shows/{id}/Seasons` and `/Shows/{id}/Episodes`, `Items/Latest`'s
plain-array response shape (handled defensively — see `index.js` — but not
seen for real), and `PlaybackInfo`'s `MediaStreams` shape for the track
pickers. The queue/"next episode" WebSocket flow and the player's
track/quality reload are new protocol surface with **no test coverage at
all yet** (no way to exercise real HLS playback without a live session) —
the first things to check once a real party actually plays something.
## Non-goals (updated)
**Music** was requested (dedicated audio player, artist/album browsing, a
shared/collaborative playlist party members can add to together) but is
deliberately not in this pass: it's a genuinely different subsystem — a
different player paradigm, full library-depth browsing down to the track,
and real-time collaborative queue state that doesn't fit the existing
"host drives, everyone follows" model at all (multiple people adding to one
shared list is a different kind of shared state than one host's playback
position). The sidebar and library routing already treat `music` as a first
-class `collectionType` specifically so slotting in a real implementation
later doesn't need another structural pass — see `library.js`'s early
return. Right now it just shows an honest placeholder.
## Known limitations
- Library tabs show top-level items only — no drilling into a Series'
seasons/episodes yet (a Series tile links out to Emby instead, see above).
- Playback always goes through `hls.js` against whatever Emby's
`PlaybackInfo` suggests. If that ever comes back as a direct-play source
with no `TranscodingUrl` (no transcoding needed/possible), the client has

View file

@ -119,7 +119,75 @@ app.get('/api/catalog/libraries', requireSession, async (req, res) => {
return;
}
const data = await embyRes.json();
res.json({ libraries: (data.Items || []).map((v) => ({ id: v.Id, name: v.Name })) });
res.json({
libraries: (data.Items || []).map((v) => ({
id: v.Id,
name: v.Name,
// "movies" / "tvshows" / "music" / others - drives which browsing
// experience client/js/views/library.js renders. See docs/architecture.md.
collectionType: v.CollectionType || null
}))
});
});
// Home page carousel: Emby's own "recently added" endpoint, one row per library.
app.get('/api/catalog/libraries/:libraryId/recent', requireSession, async (req, res) => {
const params = new URLSearchParams({ ParentId: req.params.libraryId, Limit: '16', Fields: 'ProductionYear' });
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/Latest?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les ajouts récents.' });
return;
}
const data = await embyRes.json();
// Unlike most Items endpoints, Latest returns a plain array, not {Items:[...]}.
const items = Array.isArray(data) ? data : (data.Items || []);
res.json({ items: items.map((i) => toItemDto(i, req.embySession.serverId)) });
});
// Series -> Seasons -> Episodes, for the "Contenus vidéo en série" browsing page.
app.get('/api/catalog/series/:seriesId/seasons', requireSession, async (req, res) => {
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Seasons?userId=${req.embySession.userId}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les saisons.' });
return;
}
const data = await embyRes.json();
res.json({
seasons: (data.Items || []).map((s) => ({ id: s.Id, name: s.Name, indexNumber: s.IndexNumber }))
});
});
app.get('/api/catalog/series/:seriesId/episodes', requireSession, async (req, res) => {
const params = new URLSearchParams({ userId: req.embySession.userId, Fields: 'Overview' });
if (req.query.seasonId) params.set('SeasonId', req.query.seasonId);
const embyRes = await embyFetch(`/Shows/${req.params.seriesId}/Episodes?${params}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(502).json({ error: 'Impossible de récupérer les épisodes.' });
return;
}
const data = await embyRes.json();
res.json({ episodes: (data.Items || []).map((e) => toItemDto(e, req.embySession.serverId)) });
});
// Audio/subtitle tracks available for an item, read from Emby's own playback
// negotiation (PlaybackInfo) - powers the player's language/subtitle pickers.
app.get('/api/catalog/item/:itemId/playback-options', requireSession, async (req, res) => {
const infoRes = await embyFetch(`/Items/${req.params.itemId}/PlaybackInfo?UserId=${req.embySession.userId}`, req.embySession.embyToken);
if (!infoRes.ok) {
res.status(502).json({ error: 'Impossible de lire les options de lecture.' });
return;
}
const info = await infoRes.json();
const source = (info.MediaSources || [])[0];
const streams = (source && source.MediaStreams) || [];
res.json({
audioTracks: streams
.filter((s) => s.Type === 'Audio')
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` })),
subtitleTracks: streams
.filter((s) => s.Type === 'Subtitle')
.map((s) => ({ index: s.Index, label: s.DisplayTitle || s.Language || `Piste ${s.Index}` }))
});
});
app.get('/api/catalog/libraries/:libraryId/items', requireSession, async (req, res) => {
@ -163,7 +231,17 @@ app.get('/api/catalog/stream/:itemId/master.m3u8', async (req, res) => {
// lookup (MediaSourceId/PlaySessionId/DeviceId) that URL never supplied.
// PlaybackInfo returns a MediaSource with its own ready-to-use
// TranscodingUrl carrying whatever Emby actually needs.
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?UserId=${session.userId}`, session.embyToken);
//
// AudioStreamIndex/SubtitleStreamIndex/MaxStreamingBitrate come from the
// player's language/subtitle/quality pickers - forwarded straight through
// so Emby bakes the right selection into the transcode itself (subtitles
// end up burned into the video this way, not a separate toggleable track -
// simpler than sidecar WebVTT, at the cost of a re-transcode to change).
const infoParams = new URLSearchParams({ UserId: session.userId });
for (const key of ['AudioStreamIndex', 'SubtitleStreamIndex', 'MaxStreamingBitrate']) {
if (req.query[key]) infoParams.set(key, req.query[key]);
}
const infoRes = await embyFetch(`/Items/${itemId}/PlaybackInfo?${infoParams}`, session.embyToken);
if (!infoRes.ok) {
res.status(502).json({ error: 'Impossible de préparer la lecture de ce titre.' });
return;
@ -253,16 +331,47 @@ app.post('/api/parties', requireSession, async (req, res) => {
}
const item = await embyRes.json();
// Starting a party from an episode queues the rest of the series from
// there on, so the host can hit "next" (see wsHandler.js) instead of
// everyone having to separately start a new party per episode.
let queue = null;
if (item.Type === 'Episode' && item.SeriesId) {
const epsRes = await embyFetch(
`/Shows/${item.SeriesId}/Episodes?userId=${req.embySession.userId}&Fields=Overview`,
req.embySession.embyToken
);
if (epsRes.ok) {
const epsData = await epsRes.json();
const all = epsData.Items || [];
const startIndex = all.findIndex((e) => e.Id === itemId);
const remaining = startIndex >= 0 ? all.slice(startIndex) : [item];
queue = remaining.map((e) => ({
itemId: e.Id,
itemName: formatEpisodeName(e),
itemImageUrl: `/api/catalog/image/${e.Id}`
}));
}
}
const party = PartyManager.create(
req.embySession.userId,
req.embySession.userName,
itemId,
item.Name,
`/api/catalog/image/${itemId}`
queue ? queue[0].itemName : item.Name,
`/api/catalog/image/${itemId}`,
queue
);
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});
function formatEpisodeName(episode) {
if (episode.ParentIndexNumber != null && episode.IndexNumber != null) {
const prefix = `S${episode.ParentIndexNumber}E${episode.IndexNumber}`;
return episode.SeriesName ? `${episode.SeriesName} - ${prefix} - ${episode.Name}` : `${prefix} - ${episode.Name}`;
}
return episode.Name;
}
app.post('/api/parties/:joinCode/join', requireSession, (req, res) => {
const party = PartyManager.getByJoinCode(req.params.joinCode);
if (!party) {

View file

@ -28,13 +28,23 @@ class PartyManager {
this.partyIdByJoinCode = new Map();
}
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl) {
/**
* `queue` is the ordered list of { itemId, itemName, itemImageUrl } this
* party will play through - for a movie/standalone episode it's just the
* one item; for "start watching this series from here", index.js builds
* the rest of the season/series into it. `itemId`/`itemName`/`itemImageUrl`
* always mirror queue[queueIndex] - kept as top-level fields too since
* that's what most of the UI (and the old single-item design) reads.
*/
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl, queue) {
const party = {
id: crypto.randomUUID(),
joinCode: generateJoinCode(),
itemId,
itemName,
itemImageUrl,
queue: queue && queue.length ? queue : [{ itemId, itemName, itemImageUrl }],
queueIndex: 0,
hostUserId,
createdUtc: Date.now(),
lastActivityUtc: Date.now(),
@ -81,6 +91,29 @@ class PartyManager {
}
}
/**
* Moves to the next item in the queue (e.g. next episode). Returns false
* if there's nothing after the current position. Resets every member's
* known position/pause state, since it's a new item playing from zero.
*/
advanceQueue(party) {
if (party.queueIndex >= party.queue.length - 1) {
return false;
}
party.queueIndex += 1;
const next = party.queue[party.queueIndex];
party.itemId = next.itemId;
party.itemName = next.itemName;
party.itemImageUrl = next.itemImageUrl;
for (const member of party.members.values()) {
member.positionTicks = 0;
member.isPaused = false;
member.lastUpdatedUtc = Date.now();
}
party.lastActivityUtc = Date.now();
return true;
}
/** All active parties - powers the "watch parties en cours" home section. */
listActive() {
return Array.from(this.partiesById.values());
@ -110,6 +143,9 @@ class PartyManager {
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
hasNext: party.queueIndex < party.queue.length - 1,
queueLength: party.queue.length,
queueIndex: party.queueIndex,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,

View file

@ -15,6 +15,8 @@ const { PartyManager } = require('./partyManager');
* <- {type:"members", members:[...]} (on any join/leave)
* -> {type:"playstate", isPaused, positionTicks} (host only - ignored otherwise)
* <- {type:"playstate", isPaused, positionTicks} (relayed to everyone but the host)
* -> {type:"next"} (host only - advance the queue, e.g. next episode)
* <- {type:"itemChanged", itemId, itemName, itemImageUrl, queueIndex, queueLength, hasNext}
* -> {type:"leave"}
* <- {type:"error", message}
*/
@ -71,6 +73,24 @@ function attachWebSocketServer(wss, { parseCookies, getSession }) {
return;
}
if (msg.type === 'next') {
if (userId !== party.hostUserId) {
return; // only the host can advance the queue
}
if (PartyManager.advanceQueue(party)) {
broadcast(party, {
type: 'itemChanged',
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
queueIndex: party.queueIndex,
queueLength: party.queue.length,
hasNext: party.queueIndex < party.queue.length - 1
}, null);
}
return;
}
if (msg.type === 'leave') {
PartyManager.leave(party, userId);
broadcastMembers(party);