From 07613c1cec9d37bddb460de1f35698644f3af1eb Mon Sep 17 00:00:00 2001 From: Nicolas Date: Sun, 13 Sep 2026 21:54:14 +0200 Subject: [PATCH] Redesign home page: library tabs, poster tiles, ongoing parties list - New backend endpoints: GET /api/catalog/libraries (Emby's /Users/{id}/Views), GET /api/catalog/libraries/:id/items (top-level, Recursive=false - movies as movies, TV as Series tiles), GET /api/parties (list every active party server-wide, for the home page's "watch parties en cours" section) - Item DTO gained `type` and `canWatchParty` (only Movie/Episode/Video are directly streamable - a Series tile only gets "Voir sur Emby") and `embyUrl`, a deep link into Emby's own web client built from EMBY_SERVER_URL + serverId (now captured at login) - Party creation now fetches and stores the item's name/poster so the home page's party list doesn't need a separate lookup per party - client/js/views/browse.js rewritten: ongoing-parties section, a join-by-code fallback, one tab per library, and a tile grid with a hover-revealed two-button overlay (start party / view on Emby) - the first only shown for playable types - Removed itemDetail.js/the /item/:id route (tiles act directly now, no intermediate detail page); player.js simplified to use party.itemName instead of a redundant item lookup - app.css: full-width home layout (no max-width cap, as asked), CSS grid handling all the responsive reflow, :hover/:focus-within reveal for the tile overlay with an always-visible (hover: none) fallback for touch devices Verified by actually driving a browser (mocked fetch standing in for /api/parties and /api/catalog/* - real login still needs real credentials): tab switching swaps content correctly, the overlay reveals per-tile only, a Series tile correctly omits the watch-party button, and the touch fallback shows both buttons inline without hover. See docs/architecture.md for exactly what that does and doesn't confirm (real Emby /Views and item-list response shapes are still unverified). Co-Authored-By: Claude Sonnet 5 --- client/css/app.css | 182 +++++++++++++++++++++++++++++----- client/js/app.js | 2 - client/js/embyApi.js | 10 ++ client/js/party.js | 5 + client/js/views/browse.js | 163 +++++++++++++++++++++++------- client/js/views/itemDetail.js | 58 ----------- client/js/views/player.js | 8 +- docs/architecture.md | 47 ++++++++- server/src/embyAuth.js | 1 + server/src/index.js | 69 +++++++++++-- server/src/partyManager.js | 14 ++- 11 files changed, 429 insertions(+), 130 deletions(-) delete mode 100644 client/js/views/itemDetail.js diff --git a/client/css/app.css b/client/css/app.css index 39d36b0..785ec43 100644 --- a/client/css/app.css +++ b/client/css/app.css @@ -29,21 +29,23 @@ body { #app { min-height: 100%; } +/* Login is a small centered form - everything else (the home page) is + full-width, see .home below. */ .wrap { - max-width: 480px; + max-width: 420px; margin: 0 auto; padding: 32px 20px 80px; } -.wrap.wide { max-width: 900px; } - h1 { font-size: 22px; margin: 0; } -h2 { font-size: 16px; margin: 0 0 8px; } +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; } @@ -80,8 +82,6 @@ input:focus { border-color: var(--accent); } -input[type="search"] { margin-bottom: 20px; } - button { padding: 12px 16px; border: none; @@ -94,6 +94,7 @@ button { } button:hover { background: var(--accent-bright); } +button[disabled] { opacity: .5; cursor: default; } button.linklike { background: none; @@ -106,46 +107,177 @@ button.linklike { button.linklike:hover { background: none; color: var(--accent-bright); } -.or { text-align: center; color: var(--muted); font-size: 12px; margin: 16px 0; } .code { color: var(--accent-bright); letter-spacing: .1em; } +/* ---- Home page: full width, responsive ---- */ + +.home { + width: 100%; + padding: 24px clamp(16px, 4vw, 48px) 80px; +} + +.section { margin-bottom: 32px; } + +.joinSection { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 12px; +} + +.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; +} + +.partyCard { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-header); + border-radius: 10px; + padding: 10px; +} + +.partyCard img { + width: 48px; + height: 72px; + object-fit: cover; + border-radius: 6px; + background: var(--panel); + flex-shrink: 0; +} + +.partyCard img.noImage { visibility: hidden; } + +.partyCardInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.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 { + display: flex; + gap: 8px; + overflow-x: auto; + padding-bottom: 8px; + margin-bottom: 20px; + border-bottom: 1px solid rgba(255,255,255,.1); +} + +.tab { + background: none; + color: var(--muted); + font-weight: 600; + padding: 8px 4px; + border-radius: 0; + white-space: nowrap; + border-bottom: 2px solid transparent; +} + +.tab:hover { background: none; color: var(--text); } +.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); } + .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 20px 16px; } -.card { +.tile { + position: relative; display: flex; flex-direction: column; gap: 6px; - color: var(--text); - text-decoration: none; - font-size: 13px; } -.card img { +.tile img { width: 100%; aspect-ratio: 2 / 3; object-fit: cover; border-radius: 8px; background: var(--panel); + display: block; } -.detail { +.tile img.noImage { + visibility: hidden; +} + +.tileLabel { + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Hover-reveal overlay with the two actions, on devices that actually have + hover (mouse/trackpad). Touch devices get an always-visible bar instead - + see the (hover: none) block below, since :hover is unreachable there. */ +.tileOverlay { + position: absolute; + inset: 0 0 26px 0; /* stop above .tileLabel */ display: flex; - gap: 20px; - margin-top: 16px; -} - -.detail img { - width: 160px; + flex-direction: column; + align-items: stretch; + justify-content: center; + gap: 8px; + padding: 10px; + background: rgba(0, 0, 0, .55); border-radius: 8px; - flex-shrink: 0; - background: var(--panel); + opacity: 0; + transition: opacity .15s ease; } -.detail button { width: 100%; margin-top: 8px; } +.tile:hover .tileOverlay, +.tile:focus-within .tileOverlay { + opacity: 1; +} + +.tileBtn { + padding: 8px 10px; + font-size: 12px; + text-align: center; + text-decoration: none; +} + +.tileBtnLink { + background: var(--panel); + color: var(--text); + border-radius: 8px; + font-weight: 600; +} + +.tileBtnLink:hover { background: #333; } + +@media (hover: none) { + .tileOverlay { + opacity: 1; + inset: auto 0 26px 0; + flex-direction: row; + background: linear-gradient(to top, rgba(0,0,0,.75), transparent); + padding: 6px; + } + .tileBtn { font-size: 11px; padding: 6px 4px; } +} + +/* ---- Player ---- */ .playerWrap { position: relative; @@ -172,8 +304,10 @@ button.linklike:hover { background: none; color: var(--accent-bright); } background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent); color: white; font-size: 13px; + flex-wrap: wrap; } +.overlay .itemTitle { font-weight: 600; } .overlay .members { color: var(--muted); flex: 1; } .followerNotice { diff --git a/client/js/app.js b/client/js/app.js index f14a636..631c2be 100644 --- a/client/js/app.js +++ b/client/js/app.js @@ -2,7 +2,6 @@ import { route, start, navigate } from './router.js'; import { auth, restoreSession } from './embyApi.js'; import { renderLogin } from './views/login.js'; import { renderBrowse } from './views/browse.js'; -import { renderItemDetail } from './views/itemDetail.js'; import { renderPartyPlayer } from './views/player.js'; function requireAuth(renderFn) { @@ -18,7 +17,6 @@ function requireAuth(renderFn) { route('/', () => navigate('/browse')); route('/login', renderLogin); route('/browse', requireAuth(renderBrowse)); -route('/item/:id', requireAuth(renderItemDetail)); route('/party/:id', requireAuth(renderPartyPlayer)); start(document.getElementById('app')); diff --git a/client/js/embyApi.js b/client/js/embyApi.js index 6414014..63d055f 100644 --- a/client/js/embyApi.js +++ b/client/js/embyApi.js @@ -58,6 +58,16 @@ export async function getItem(itemId) { return apiFetch(`/api/catalog/item/${itemId}`); } +export async function getLibraries() { + const data = await apiFetch('/api/catalog/libraries'); + return data.libraries || []; +} + +export async function getLibraryItems(libraryId) { + const data = await apiFetch(`/api/catalog/libraries/${libraryId}/items`); + return data.items || []; +} + export function streamUrl(itemId) { return `/api/catalog/stream/${itemId}/master.m3u8`; } diff --git a/client/js/party.js b/client/js/party.js index dee166b..a8af3d5 100644 --- a/client/js/party.js +++ b/client/js/party.js @@ -15,6 +15,11 @@ async function apiFetch(path, options = {}) { return res.json(); } +export async function listActiveParties() { + const data = await apiFetch('/api/parties'); + return data.parties || []; +} + export function createParty(itemId) { return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) }); } diff --git a/client/js/views/browse.js b/client/js/views/browse.js index 13f0495..338a3e3 100644 --- a/client/js/views/browse.js +++ b/client/js/views/browse.js @@ -1,15 +1,30 @@ -import { searchItems, auth, logout } from '../embyApi.js'; +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 () => { @@ -17,39 +32,119 @@ export function renderBrowse(root) { navigate('/login'); }); - const input = root.querySelector('#search'); - const results = root.querySelector('#results'); - let debounceTimer; - - input.addEventListener('input', () => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(runSearch, 300); + 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; + } }); - async function runSearch() { - const query = input.value; - if (!query || query.trim().length < 2) { - results.innerHTML = ''; - return; - } - let items; - try { - items = await searchItems(query); - } catch (err) { - results.innerHTML = '

Recherche impossible - le serveur Emby est-il joignable ?

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

Aucun résultat.

'; - return; - } - results.innerHTML = items.map((item) => ` - - - ${escapeHtml(item.name)}${item.year ? ' (' + item.year + ')' : ''} - - `).join(''); + 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) { diff --git a/client/js/views/itemDetail.js b/client/js/views/itemDetail.js deleted file mode 100644 index 0b6df36..0000000 --- a/client/js/views/itemDetail.js +++ /dev/null @@ -1,58 +0,0 @@ -import { getItem } from '../embyApi.js'; -import { createParty, joinParty } from '../party.js'; -import { navigate } from '../router.js'; - -export function renderItemDetail(root, params) { - root.innerHTML = '

Chargement...

'; - - getItem(params.id).then((item) => { - root.innerHTML = ` -
- -
- -
-

${escapeHtml(item.name)}

-

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

- - -
ou
- - -

-
-
-
`; - - root.querySelector('#back').addEventListener('click', () => history.back()); - - root.querySelector('#createBtn').addEventListener('click', async () => { - try { - const party = await createParty(item.id); - navigate(`/party/${party.partyId}`); - } catch (err) { - root.querySelector('#error').textContent = err.message; - } - }); - - root.querySelector('#joinBtn').addEventListener('click', async () => { - const code = root.querySelector('#joinCode').value.trim(); - try { - const party = await joinParty(code); - navigate(`/party/${party.partyId}`); - } catch (err) { - root.querySelector('#error').textContent = err.message; - } - }); - }).catch(() => { - root.innerHTML = '

Titre introuvable.

'; - }); -} - -function escapeHtml(str) { - const div = document.createElement('div'); - div.textContent = str; - return div.innerHTML; -} diff --git a/client/js/views/player.js b/client/js/views/player.js index f3f8c67..faaccd1 100644 --- a/client/js/views/player.js +++ b/client/js/views/player.js @@ -1,4 +1,4 @@ -import { getItem, streamUrl } from '../embyApi.js'; +import { streamUrl } from '../embyApi.js'; import { getParty, leaveParty, connectParty } from '../party.js'; import { navigate } from '../router.js'; @@ -12,6 +12,7 @@ export function renderPartyPlayer(root, params) {
+ Code: @@ -20,6 +21,7 @@ export function renderPartyPlayer(root, params) {
`; const video = root.querySelector('#video'); + const itemTitleEl = root.querySelector('#itemTitle'); const codeEl = root.querySelector('#code'); const membersEl = root.querySelector('#members'); const followerNotice = root.querySelector('#followerNotice'); @@ -56,12 +58,12 @@ export function renderPartyPlayer(root, params) { } isHost = party.youAreHost; + itemTitleEl.textContent = party.itemName || ''; codeEl.textContent = party.joinCode; video.controls = isHost; followerNotice.classList.toggle('hide', isHost); - const item = await getItem(party.itemId); - const src = streamUrl(item.id); + const src = streamUrl(party.itemId); if (window.Hls && window.Hls.isSupported()) { const hls = new window.Hls(); hls.loadSource(src); diff --git a/docs/architecture.md b/docs/architecture.md index 47e45a2..e98a4a0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -162,9 +162,52 @@ point is confirmed: reasoned about, never run against a playlist Emby actually generated. If video loads the manifest but segments 404 or hang, start here. - **Response field names** (`AccessToken`, `User.Id`, `User.Name`, - `Items[].Id/Name/Overview/ProductionYear`) — long-standing Emby/Jellyfin - convention, not independently re-confirmed for this Emby version. + `ServerId`, `Items[].Id/Name/Overview/ProductionYear/Type`, + `/Users/{id}/Views` returning library folders the same shape as regular + items) — long-standing Emby/Jellyfin convention, not independently + re-confirmed for this Emby version. - **`X-Emby-Authorization` header format** — same caveat as above. +- **The Emby web client deep-link shape** + (`/web/index.html#!/item?id=X&serverId=Y`) used for "Voir sur Emby" — + matches the long-standing convention, not confirmed against this exact + build's routing. + +## Home page: library tabs, tiles, ongoing parties + +`client/js/views/browse.js` (the home page, route `/browse`) is now: + +- **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. + +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). ## Known limitations diff --git a/server/src/embyAuth.js b/server/src/embyAuth.js index 45ae80b..4941baa 100644 --- a/server/src/embyAuth.js +++ b/server/src/embyAuth.js @@ -59,6 +59,7 @@ async function login(username, password) { embyToken: data.AccessToken, userId: data.User.Id, userName: data.User.Name, + serverId: data.ServerId, // needed to build "open in Emby" deep links createdAt: Date.now() }); return sessionId; diff --git a/server/src/index.js b/server/src/index.js index b96d459..cbdd289 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -99,7 +99,7 @@ app.get('/api/catalog/search', requireSession, async (req, res) => { return; } const data = await embyRes.json(); - res.json({ items: (data.Items || []).map(toItemDto) }); + res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) }); }); app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => { @@ -108,7 +108,36 @@ app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => { res.status(embyRes.status).json({ error: 'Titre introuvable.' }); return; } - res.json(toItemDto(await embyRes.json())); + res.json(toItemDto(await embyRes.json(), req.embySession.serverId)); +}); + +// One tab per library, e.g. Films / Séries - see client/js/views/browse.js. +app.get('/api/catalog/libraries', requireSession, async (req, res) => { + const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Views`, req.embySession.embyToken); + if (!embyRes.ok) { + res.status(502).json({ error: 'Impossible de récupérer les bibliothèques.' }); + return; + } + const data = await embyRes.json(); + res.json({ libraries: (data.Items || []).map((v) => ({ id: v.Id, name: v.Name })) }); +}); + +app.get('/api/catalog/libraries/:libraryId/items', requireSession, async (req, res) => { + const params = new URLSearchParams({ + ParentId: req.params.libraryId, + Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode) + SortBy: 'SortName', + Fields: 'Overview,ProductionYear', + ImageTypeLimit: '1', + Limit: '300' + }); + const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken); + if (!embyRes.ok) { + res.status(502).json({ error: 'Impossible de récupérer le contenu de cette bibliothèque.' }); + return; + } + const data = await embyRes.json(); + res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) }); }); // Images/video are requested by /