All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Big UI pass towards looking/behaving like Emby. Deferred: music (needs
its own player + a real-time collaborative playlist - different enough
subsystem to be its own pass; sidebar/routing already treat it as a
first-class collectionType, page just says "not yet" for now).
Layout:
- client/js/shell.js: persistent sidebar (one entry per Emby library,
from GET /api/catalog/libraries which now also returns
collectionType) + a #mainContent the router swaps on navigation.
router.js made idempotent (start() safe to call more than once) so
a login -> shell boot can happen twice in one page lifetime (logout
then back in) without piling up duplicate hashchange listeners.
- app.js's boot() renders either the bare login screen or the full
shell depending on session state; login.js calls it again on
success instead of navigate() (no router running yet at that point).
Browsing, by collection type:
- views/home.js (route /home): a carousel of every active party
server-wide, a join-by-code fallback, then one "recently added"
carousel per non-music library (GET /api/catalog/libraries/:id/recent,
Emby's Items/Latest).
- views/library.js (route /library/:id): dispatches on collectionType -
movies/tvshows get the tile grid (Series tiles link into /series/:id
instead of starting a party directly), music gets an honest
placeholder.
- views/series.js (route /series/:id): season tabs + episode list
(new GET /api/catalog/series/:id/seasons and .../episodes, Emby's
/Shows/{id}/Seasons and /Episodes).
- tiles.js: poster-tile rendering shared by home.js and library.js so
they can't drift apart.
Episode queue / "next episode":
- POST /api/parties now builds a queue when starting from an episode
(fetches the rest of the series, from that episode onward) -
partyManager.js gained queue/queueIndex/advanceQueue.
- New WS message {type:"next"} (host-only) advances the queue and
broadcasts {type:"itemChanged", itemId, itemName, ...}; every client
reloads the player against the new item. player.js shows "Épisode
suivant" when hasNext, and auto-sends next() on the video's `ended`
event.
Player: language/subtitles/quality:
- New GET /api/catalog/item/:id/playback-options exposes PlaybackInfo's
MediaStreams as audio/subtitle track lists.
- master.m3u8 now forwards AudioStreamIndex/SubtitleStreamIndex/
MaxStreamingBitrate to PlaybackInfo; player.js has three <select>s
(host-only) that reload the stream at the same position on change.
Subtitles end up burned into the transcode (via SubtitleStreamIndex),
not a toggleable track - simpler than sidecar WebVTT for this pass.
"Quality" is a MaxStreamingBitrate ceiling, not true adaptive bitrate.
Verified by driving a browser against mocked /api/* responses (see
session history): sidebar renders/highlights correctly, home carousels
render (parties + one row per non-music library, music correctly
skipped), library grid dispatch works, series page season-tab
switching + episode list render and the season/episode navigation
flow works end to end. Real Emby response shapes for all of this
(Views' CollectionType, Seasons/Episodes, Items/Latest, PlaybackInfo's
MediaStreams) and the queue/track-picker protocol are NOT yet
confirmed against a live server - see docs/architecture.md.
Removed client/js/views/browse.js, superseded by home.js + library.js.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
93 lines
3.3 KiB
JavaScript
93 lines
3.3 KiB
JavaScript
import { getItem, getSeriesSeasons, getSeriesEpisodes } from '../embyApi.js';
|
|
import { createParty } from '../party.js';
|
|
import { navigate } from '../router.js';
|
|
import { escapeHtml } from '../tiles.js';
|
|
|
|
export async function renderSeries(root, params) {
|
|
const seriesId = params.id;
|
|
root.innerHTML = '<p class="hint">Chargement...</p>';
|
|
|
|
let series;
|
|
let seasons;
|
|
try {
|
|
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
|
|
} catch (err) {
|
|
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
|
|
return;
|
|
}
|
|
|
|
root.innerHTML = `
|
|
<button class="linklike" id="back">← Retour</button>
|
|
<div class="pageHeader"><h1>${escapeHtml(series.name)}</h1></div>
|
|
<nav id="seasonTabs" class="tabs"></nav>
|
|
<div id="episodeList" class="episodeList"><p class="hint">Chargement...</p></div>`;
|
|
|
|
root.querySelector('#back').addEventListener('click', () => history.back());
|
|
|
|
if (!seasons.length) {
|
|
root.querySelector('#episodeList').innerHTML = '<p class="hint">Aucune saison trouvée.</p>';
|
|
return;
|
|
}
|
|
|
|
const tabsEl = root.querySelector('#seasonTabs');
|
|
tabsEl.innerHTML = seasons
|
|
.map((s, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-season-id="${s.id}">${escapeHtml(s.name)}</button>`)
|
|
.join('');
|
|
|
|
tabsEl.querySelectorAll('.tab').forEach((tab) => {
|
|
tab.addEventListener('click', () => {
|
|
if (tab.classList.contains('active')) return;
|
|
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
loadEpisodes(root, seriesId, tab.dataset.seasonId);
|
|
});
|
|
});
|
|
|
|
loadEpisodes(root, seriesId, seasons[0].id);
|
|
}
|
|
|
|
async function loadEpisodes(root, seriesId, seasonId) {
|
|
const listEl = root.querySelector('#episodeList');
|
|
listEl.innerHTML = '<p class="hint">Chargement...</p>';
|
|
|
|
let episodes;
|
|
try {
|
|
episodes = await getSeriesEpisodes(seriesId, seasonId);
|
|
} catch (err) {
|
|
listEl.innerHTML = '<p class="error">Impossible de charger les épisodes.</p>';
|
|
return;
|
|
}
|
|
if (!episodes.length) {
|
|
listEl.innerHTML = '<p class="hint">Aucun épisode dans cette saison.</p>';
|
|
return;
|
|
}
|
|
|
|
// Starting a party from any episode here queues the rest of the series
|
|
// from that point on server-side (see server/src/index.js) - the host can
|
|
// then use "Épisode suivant" in the player instead of restarting per episode.
|
|
listEl.innerHTML = episodes.map((ep) => `
|
|
<div class="episodeRow">
|
|
<img src="${ep.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
|
<div class="episodeInfo">
|
|
<strong>${ep.year ? ep.year + ' · ' : ''}${escapeHtml(ep.name)}</strong>
|
|
<p class="hint">${escapeHtml(ep.overview || '')}</p>
|
|
</div>
|
|
<button class="startEpisodeBtn" data-item-id="${ep.id}">Lancer une watch party</button>
|
|
</div>
|
|
`).join('');
|
|
|
|
listEl.querySelectorAll('.startEpisodeBtn').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Démarrage...';
|
|
try {
|
|
const party = await createParty(btn.dataset.itemId);
|
|
navigate(`/party/${party.partyId}`);
|
|
} catch (err) {
|
|
alert(err.message);
|
|
btn.disabled = false;
|
|
btn.textContent = 'Lancer une watch party';
|
|
}
|
|
});
|
|
});
|
|
}
|