watchparty/client/src/views/series/Series.ts
Nicolas ff664f2c2b
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Replace every blocking alert() with a light toast, fix a mobile grid bug
Every message this app ever showed the client - the host-handoff
notice, every "couldn't start/join a party" error - went through a
blocking alert(), freezing the tab for what's usually just a heads-up.
New toast.ts is a self-mounting singleton (no per-party state, so it
doesn't need Shell.ts wiring like musicBar/MusicBar does) styled after
Player.css's own .requestBanner, with an 'error' variant that's still
just a plain left accent, not a red banner - even a failed click
doesn't need this app to suddenly shout. All ten alert() call sites
across Player/MusicBar/tiles/Home/Series/Playlist/Artist/Album now go
through it.

Also fixes a real reported bug while in the area: the library/artist
grid's minmax(160px, 1fr) floor left no room for a second column once
a real phone's padding was subtracted, unlike everything else mobile
in this app (the sidebar, the music bar) which already got a
narrow-screen treatment. Switched to a fluid clamp(100px, 28vw, 160px)
floor instead of a fixed breakpoint, confirmed at seven real widths via
an iframe (its own independent viewport for vw units, sidestepping
this sandbox's own unreliable viewport-resize emulation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:34:10 +02:00

105 lines
4 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 { showToast } from '../../toast';
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) {
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = 'Lancer une watch party';
}
});
});
}