watchparty/client/js/views/home.js
Nicolas f563f5b0a2
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Emby-style UI: sidebar, carousels, series drill-down, episode queue, player tracks
Big UI pass towards looking/behaving like Emby. Deferred: music (needs
its own player + a real-time collaborative playlist - different enough
subsystem to be its own pass; sidebar/routing already treat it as a
first-class collectionType, page just says "not yet" for now).

Layout:
- client/js/shell.js: persistent sidebar (one entry per Emby library,
  from GET /api/catalog/libraries which now also returns
  collectionType) + a #mainContent the router swaps on navigation.
  router.js made idempotent (start() safe to call more than once) so
  a login -> shell boot can happen twice in one page lifetime (logout
  then back in) without piling up duplicate hashchange listeners.
- app.js's boot() renders either the bare login screen or the full
  shell depending on session state; login.js calls it again on
  success instead of navigate() (no router running yet at that point).

Browsing, by collection type:
- views/home.js (route /home): a carousel of every active party
  server-wide, a join-by-code fallback, then one "recently added"
  carousel per non-music library (GET /api/catalog/libraries/:id/recent,
  Emby's Items/Latest).
- views/library.js (route /library/:id): dispatches on collectionType -
  movies/tvshows get the tile grid (Series tiles link into /series/:id
  instead of starting a party directly), music gets an honest
  placeholder.
- views/series.js (route /series/:id): season tabs + episode list
  (new GET /api/catalog/series/:id/seasons and .../episodes, Emby's
  /Shows/{id}/Seasons and /Episodes).
- tiles.js: poster-tile rendering shared by home.js and library.js so
  they can't drift apart.

Episode queue / "next episode":
- POST /api/parties now builds a queue when starting from an episode
  (fetches the rest of the series, from that episode onward) -
  partyManager.js gained queue/queueIndex/advanceQueue.
- New WS message {type:"next"} (host-only) advances the queue and
  broadcasts {type:"itemChanged", itemId, itemName, ...}; every client
  reloads the player against the new item. player.js shows "Épisode
  suivant" when hasNext, and auto-sends next() on the video's `ended`
  event.

Player: language/subtitles/quality:
- New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's
  MediaStreams as audio/subtitle track lists.
- master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/
  MaxStreamingBitrate to PlaybackInfo; player.js has three <select>s
  (host-only) that reload the stream at the same position on change.
  Subtitles end up burned into the transcode (via SubtitleStreamIndex),
  not a toggleable track - simpler than sidecar WebVTT for this pass.
  "Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 22:44:49 +02:00

98 lines
3.3 KiB
JavaScript

import { getLibraries, getRecentItems } from '../embyApi.js';
import { listActiveParties, createParty, joinParty } from '../party.js';
import { navigate } from '../router.js';
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
export function renderHome(root) {
root.innerHTML = `
<div class="pageHeader">
<h1>Accueil</h1>
<div class="joinInline">
<input id="joinCode" maxlength="6" placeholder="Code de party" style="text-transform:uppercase" />
<button id="joinBtn">Rejoindre</button>
</div>
</div>
<p id="joinError" class="error"></p>
<section class="section" id="partiesSection" hidden>
<h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div>`;
root.querySelector('#joinBtn').addEventListener('click', async () => {
const code = root.querySelector('#joinCode').value.trim();
if (!code) return;
try {
const party = await joinParty(code);
navigate(`/party/${party.partyId}`);
} catch (err) {
root.querySelector('#joinError').textContent = err.message;
}
});
loadParties(root);
loadRecentSections(root);
}
async function loadParties(root) {
let parties;
try {
parties = await listActiveParties();
} catch (err) {
return; // non-critical - the rest of the home page still works
}
if (!parties.length) return;
root.querySelector('#partiesSection').hidden = false;
root.querySelector('#partiesCarousel').innerHTML = parties.map((p) => `
<div class="partyCard">
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}</span>
</div>
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
</div>
`).join('');
root.querySelectorAll('.joinPartyBtn').forEach((btn) => {
btn.addEventListener('click', () => navigate(`/party/${btn.dataset.partyId}`));
});
}
async function loadRecentSections(root) {
const container = root.querySelector('#recentSections');
let libraries;
try {
libraries = await getLibraries();
} catch (err) {
container.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
return;
}
for (const lib of libraries) {
if (lib.collectionType === 'music') continue; // music gets its own experience later, not a poster row
const section = document.createElement('section');
section.className = 'section';
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`;
container.appendChild(section);
getRecentItems(lib.id)
.then((items) => {
if (!items.length) {
section.remove();
return;
}
const carousel = section.querySelector('.carousel');
carousel.innerHTML = items.map(tileHtml).join('');
wireTileButtons(carousel, async (itemId) => {
const party = await createParty(itemId);
navigate(`/party/${party.partyId}`);
});
})
.catch(() => section.remove());
}
}