Redesign home page: library tabs, poster tiles, ongoing parties list
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s

- 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 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 21:54:14 +02:00
parent b6818655b1
commit 07613c1cec
11 changed files with 429 additions and 130 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = `
<main class="wrap wide">
<main class="home">
<div class="topbar">
<h1>Watch Party</h1>
<button class="linklike" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
</div>
<input id="search" type="search" placeholder="Cherche un film, une série, un épisode..." autofocus />
<div id="results" class="grid"></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 () => {
@ -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 = '';
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 searchItems(query);
items = await getLibraryItems(libraryId);
} catch (err) {
results.innerHTML = '<p class="error">Recherche impossible - le serveur Emby est-il joignable ?</p>';
contentEl.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
return;
}
if (!items.length) {
results.innerHTML = '<p class="hint">Aucun résultat.</p>';
contentEl.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
return;
}
results.innerHTML = items.map((item) => `
<a class="card" href="#/item/${item.id}">
<img src="${item.imageUrl}" alt="" loading="lazy" />
<span>${escapeHtml(item.name)}${item.year ? ' (' + item.year + ')' : ''}</span>
</a>
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) {

View file

@ -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 = '<main class="wrap"><p class="hint">Chargement...</p></main>';
getItem(params.id).then((item) => {
root.innerHTML = `
<main class="wrap">
<button class="linklike" id="back">&larr; Retour</button>
<div class="detail">
<img src="${item.imageUrl}" alt="" />
<div>
<h1>${escapeHtml(item.name)}</h1>
<p>${escapeHtml(item.overview || '')}</p>
<button id="createBtn">Démarrer une watch party</button>
<div class="or">ou</div>
<label>Code de la party
<input id="joinCode" maxlength="6" style="text-transform:uppercase" />
</label>
<button id="joinBtn">Rejoindre</button>
<p id="error" class="error"></p>
</div>
</div>
</main>`;
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 = '<main class="wrap"><p class="error">Titre introuvable.</p></main>';
});
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

View file

@ -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) {
<div class="playerWrap">
<video id="video" playsinline></video>
<div class="overlay">
<span id="itemTitle" class="itemTitle"></span>
<span>Code: <span id="code" class="code"></span></span>
<span id="members" class="members"></span>
<button id="leaveBtn" class="linklike">Quitter</button>
@ -20,6 +21,7 @@ export function renderPartyPlayer(root, params) {
</div>`;
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);

View file

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

View file

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

View file

@ -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 <img>/<video> elements, which send the
@ -150,25 +179,53 @@ app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
await proxyEmbyResponse(req, res, pathAndQuery, session.embyToken, itemId);
});
function toItemDto(item) {
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video']);
function toItemDto(item, serverId) {
return {
id: item.Id,
name: item.Name,
overview: item.Overview,
year: item.ProductionYear,
imageUrl: `/api/catalog/image/${item.Id}`
type: item.Type,
// Series/Season aren't directly playable - only show "start a party" for
// things Emby can actually stream. "Voir sur Emby" always works though.
canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
imageUrl: `/api/catalog/image/${item.Id}`,
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`
};
}
// --- Parties ---------------------------------------------------------------
app.post('/api/parties', requireSession, (req, res) => {
app.get('/api/parties', requireSession, (req, res) => {
// Everyone on the server sees every active party here - that's the point:
// it's how the home page's "watch parties en cours" list works, so people
// can join without needing a code passed to them out of band.
res.json({ parties: PartyManager.listActive().map((p) => PartyManager.toPublicState(p, req.embySession.userId)) });
});
app.post('/api/parties', requireSession, async (req, res) => {
const itemId = req.body && req.body.itemId;
if (!itemId) {
res.status(400).json({ error: 'itemId is required.' });
return;
}
const party = PartyManager.create(req.embySession.userId, req.embySession.userName, itemId);
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${itemId}`, req.embySession.embyToken);
if (!embyRes.ok) {
res.status(404).json({ error: 'Titre introuvable.' });
return;
}
const item = await embyRes.json();
const party = PartyManager.create(
req.embySession.userId,
req.embySession.userName,
itemId,
item.Name,
`/api/catalog/image/${itemId}`
);
res.json(PartyManager.toPublicState(party, req.embySession.userId));
});

View file

@ -28,11 +28,13 @@ class PartyManager {
this.partyIdByJoinCode = new Map();
}
create(hostUserId, hostUserName, itemId) {
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl) {
const party = {
id: crypto.randomUUID(),
joinCode: generateJoinCode(),
itemId,
itemName,
itemImageUrl,
hostUserId,
createdUtc: Date.now(),
lastActivityUtc: Date.now(),
@ -79,6 +81,11 @@ class PartyManager {
}
}
/** All active parties - powers the "watch parties en cours" home section. */
listActive() {
return Array.from(this.partiesById.values());
}
removeIdleParties(idleTimeoutMs) {
const cutoff = Date.now() - idleTimeoutMs;
for (const party of this.partiesById.values()) {
@ -96,10 +103,15 @@ class PartyManager {
* an id to compare with at all.
*/
toPublicState(party, viewerUserId) {
const host = party.members.get(party.hostUserId);
return {
partyId: party.id,
joinCode: party.joinCode,
itemId: party.itemId,
itemName: party.itemName,
itemImageUrl: party.itemImageUrl,
hostUserName: host ? host.userName : null,
memberCount: party.members.size,
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
members: Array.from(party.members.values()).map((m) => ({
userId: m.userId,