Every "Chargement..." spot across the app was plain text with no
visual motion - easy to mistake a still-fetching page for a stuck one.
New .spinner in styles/base.css - a plain CSS ring (border +
border-top-color, no image/icon-font asset), sized and vertically
aligned to drop into an existing text node inline instead of needing
a flex-row restructure. prefers-reduced-motion turns the spin off.
New client/src/loading.ts exports one shared LOADING_HINT constant so
every dynamic call site (Album/Artist/Library/Playlist/Series's own
route entry, Home's per-library carousels, a season switch reloading
its episode list, the player's route-level load) renders identical
markup. Shell.html's sidebar library list and Library.html's #grid
placeholder (both static templates, no JS import) got the same
markup pasted directly. Player.html's #loadingNotice (video
buffering) got the spinner too.
Left alone: a couple of static templates' own inert copies of this
same placeholder text (Playlist.html, Series.html) that are always
synchronously overwritten before ever painting - nothing a viewer
could ever actually see differently. Also left alone: the brief
button-label loading states elsewhere ("Ajout...", "Démarrage...") -
a different, smaller UI element, typically resolved within one REST
round trip.
Verified: full typecheck/build on the client (no server changes at
all). Visually confirmed in a real browser that the spinner renders
correctly inline with text and is genuinely animating.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
104 lines
3.9 KiB
TypeScript
104 lines
3.9 KiB
TypeScript
import './Series.css';
|
|
import html from './Series.html';
|
|
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
|
|
import { createParty } from '../../api/party';
|
|
import { navigate } from '../../router';
|
|
import type { RouteParams } from '../../router';
|
|
import { escapeHtml } from '../../tiles';
|
|
import { LOADING_HINT } from '../../loading';
|
|
import type { ItemDto, SeasonDto } from '../../types';
|
|
|
|
export async function renderSeries(root: HTMLElement, params: RouteParams): Promise<void> {
|
|
const seriesId = params.id as string;
|
|
root.innerHTML = LOADING_HINT;
|
|
|
|
let series: ItemDto;
|
|
let seasons: SeasonDto[];
|
|
try {
|
|
[series, seasons] = await Promise.all([getItem(seriesId), getSeriesSeasons(seriesId)]);
|
|
} catch {
|
|
root.innerHTML = '<p class="error">Impossible de charger cette série.</p>';
|
|
return;
|
|
}
|
|
|
|
root.innerHTML = html;
|
|
root.querySelector('#back')!.addEventListener('click', () => history.back());
|
|
(root.querySelector('#seriesPoster') as HTMLImageElement).src = series.imageUrl;
|
|
root.querySelector('#seriesTitle')!.innerHTML = series.year
|
|
? `${escapeHtml(series.name)} <span class="seriesYear">(${series.year})</span>`
|
|
: escapeHtml(series.name);
|
|
if (series.overview) {
|
|
const overviewEl = root.querySelector('#seriesOverview')!;
|
|
overviewEl.textContent = series.overview;
|
|
overviewEl.classList.remove('hide');
|
|
}
|
|
(root.querySelector('#embyLink') as HTMLAnchorElement).href = series.embyUrl;
|
|
|
|
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<HTMLButtonElement>('.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 as string);
|
|
});
|
|
});
|
|
|
|
loadEpisodes(root, seriesId, seasons[0]!.id);
|
|
}
|
|
|
|
async function loadEpisodes(root: HTMLElement, seriesId: string, seasonId: string): Promise<void> {
|
|
const listEl = root.querySelector('#episodeList')!;
|
|
listEl.innerHTML = LOADING_HINT;
|
|
|
|
let episodes: ItemDto[];
|
|
try {
|
|
episodes = await getSeriesEpisodes(seriesId, seasonId);
|
|
} catch {
|
|
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 CatalogService/index.ts) - 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<HTMLButtonElement>('.startEpisodeBtn').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Démarrage...';
|
|
try {
|
|
const party = await createParty(btn.dataset.itemId as string);
|
|
navigate(`/party/${party.partyId}`);
|
|
} catch (err) {
|
|
alert((err as Error).message);
|
|
btn.disabled = false;
|
|
btn.textContent = 'Lancer une watch party';
|
|
}
|
|
});
|
|
});
|
|
}
|