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 ` +
+ ${seriesHref ? `` : ''} + +
+ ${item.canWatchParty ? `` : ''} + ${seriesHref ? `Voir les épisodes` : ''} + Voir sur Emby +
+ ${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''} +
`; +} + +/** + * 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'; + } + }); + }); +} diff --git a/client/js/views/browse.js b/client/js/views/browse.js deleted file mode 100644 index 338a3e3..0000000 --- a/client/js/views/browse.js +++ /dev/null @@ -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 = ` -
-
-

Watch Party

- -
- -
-

Watch parties en cours

-
-
- -
- - -

-
- - -

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 `