watchparty/client/src/views/series/Series.ts
Nicolas 5182def5c9
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Add static (persistent) watch parties
A watch party built from a whole series or video playlist that remembers
the group's progress and each member's, so someone coming back or joining
late sees the items they missed and can catch up (solo, as a catch-up
watch party, or by marking items watched).

- StaticPartyService + StaticPartyStore: first on-disk state (JSON file
  under DATA_DIR, atomic debounced writes, flushed on SIGTERM)
- each session is an ordinary Party linked via party.staticLink; group
  position is saved by the 20s sweep and resumed on the next session
- PartyService.completeCurrentItem centralizes "item done" (Emby Played +
  static progress); new host-only "itemEnded" socket event covers the
  last queued item, which was never marked Played before
- missed = group-completed minus watched in-app minus Emby Played
- /static/:id view, create buttons on series/playlist pages, home section
- Docker volume for /data, spec/static-watch-party.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:54:47 +02:00

107 lines
4.1 KiB
TypeScript

import './Series.css';
import html from './Series.html';
import { getItem, getSeriesEpisodes, getSeriesSeasons } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { wireCreateStaticButton } from '../staticParty/createStaticButton';
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;
wireCreateStaticButton(root.querySelector('#createStaticBtn') as HTMLButtonElement, seriesId);
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';
}
});
});
}