watchparty/client/js/views/series.js
Nicolas f6a8010647
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Move presence overlay to the top, orange badge for pause requests, series detail page
Two rounds of feedback on the previous commit, plus a related feature
request:

1. Presence overlay moved from bottom-left to the top of the player.
   client/js/views/player.js: the top gradient bar is now .topOverlay, a
   column of two stacked rows - .actionBar (what used to be the whole bar:
   title/members-text/next/settings/leave) and #presenceOverlay directly
   below it. Stacking them in normal flow means the avatar row always
   lands right after the action bar regardless of how tall that bar
   renders (it wraps on narrow screens), instead of a second
   absolutely-positioned block at a guessed pixel offset.

2. "Someone requested a pause" is now a specific orange border
   (.avatarPauseRequested, #ff9800) plus a small pause-icon badge in the
   circle's corner (.avatarPauseBadge - a plain inline two-bar SVG, not a
   Unicode glyph, since pause/play characters default to emoji
   presentation on several platforms and look muddy at 10px) - replacing
   the previous generic box-shadow pulse. This also absorbed the separate
   "click feedback" ring that used to live only on the requester's own
   circle (.avatarSent): since the server already broadcasts
   "pauseRequested" back to the requester too, one state driven purely by
   that broadcast is simpler than two overlapping ones.

   Found while repositioning the banner above .followerNotice: the
   notice is a <p>, and for an absolutely positioned element "bottom"
   places the MARGIN edge, so its default ~1em user-agent margin was
   quietly rendering it 13px higher than its declared bottom:24px ever
   suggested. Gave it margin:0.

3. Series detail page (client/js/views/series.js): the page now opens
   with an actual detail header - poster, title + year, overview, "Voir
   sur Emby" - above the season tabs/episode list, instead of a bare <h1>.
   The season/episode browsing and per-episode "Lancer une watch party"
   already worked end to end (library grid -> Series tile -> this route);
   this pass only made the page look like a detail page. Reused
   toItemDto's existing fields (GET /api/catalog/item/:id), no backend
   changes needed. Widened .linklike's CSS selector from
   `button.linklike` to `.linklike` so the new "Voir sur Emby" <a> picks
   it up too - app.css has no global `a` reset, so an unclassed anchor
   would otherwise render with the browser's default blue/underline.

Verified with mocked Socket.IO events / API responses against the real
running local server (see session history): presence row renders directly
under the action bar; a pauseRequested event gives the right circle
rgb(255, 152, 0) as its computed border color and display:flex on its
badge while leaving every other circle untouched (checked via computed
styles, not just class names); rapid repeat clicks on your own circle
still collapse to exactly one requestPause emission; banner/followerNotice
gap measured 15px after the margin fix, versus 2px before it; the series
page's header renders correctly, season-tab switching swaps the episode
list, a tvshows library's Series tiles link to /series/:id via both the
poster and "Voir les épisodes", and starting an episode's party correctly
creates it and navigates to the player.

docs/architecture.md has the full writeup.

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

100 lines
3.7 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">&larr; Retour</button>
<div class="seriesHeader">
<img class="seriesPoster" src="${series.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="seriesInfo">
<h1>${escapeHtml(series.name)}${series.year ? ` <span class="seriesYear">(${series.year})</span>` : ''}</h1>
${series.overview ? `<p class="seriesOverview">${escapeHtml(series.overview)}</p>` : ''}
<a class="linklike" href="${series.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
</div>
</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';
}
});
});
}