watchparty/client/src/views/staticParty/StaticParty.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

219 lines
9.7 KiB
TypeScript

import './StaticParty.css';
import html from './StaticParty.html';
import {
deleteStaticParty,
getStaticParty,
joinStaticParty,
leaveStaticParty,
markStaticItemWatched,
startCatchUp,
startStaticSession
} from '../../api/staticParty';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import { showToast } from '../../toast';
import type { StaticPartyDetailDto, StaticPartyItemDto } from '../../types';
/**
* Route `/static/:id` - one static (persistent) watch party: the group's
* progress through the whole series/playlist, its members, and - the
* point of the feature - what *you* missed, with ways to catch up (alone,
* or as a watch party others can join) or mark things as already seen.
* Launching the group's session hands off to the normal player
* (`/party/:id`) - a session is an ordinary party server-side. See
* server/src/services/StaticPartyService.ts and spec/static-watch-party.md.
*/
export async function renderStaticParty(root: HTMLElement, params: RouteParams): Promise<void> {
const staticId = params.id as string;
root.innerHTML = LOADING_HINT;
let detail: StaticPartyDetailDto;
try {
detail = await getStaticParty(staticId);
} catch (err) {
console.error('[staticParty] load failed', staticId, err);
root.innerHTML = `<p class="error">${escapeHtml((err as Error).message || 'Impossible de charger cette watch party.')}</p>`;
return;
}
root.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back());
wireActions(root, staticId);
render(root, detail);
}
/** Re-fetches and re-renders in place - after any action that changed
* progress or membership. */
async function reload(root: HTMLElement, staticId: string): Promise<void> {
try {
const detail = await getStaticParty(staticId);
if (!root.querySelector('#staticTitle')) return; // navigated away mid-flight
render(root, detail);
} catch (err) {
console.error('[staticParty] reload failed', staticId, err);
showToast((err as Error).message, { variant: 'error' });
}
}
/** Fills every part of the page from `detail`. */
function render(root: HTMLElement, detail: StaticPartyDetailDto): void {
(root.querySelector('#staticCover') as HTMLImageElement).src = detail.imageUrl;
root.querySelector('#staticTitle')!.textContent = detail.name;
const sourceLabel = detail.sourceType === 'series' ? 'Série' : 'Playlist';
root.querySelector('#staticMeta')!.textContent =
`${sourceLabel} · ${detail.groupWatchedCount}/${detail.itemCount} vus par le groupe · ${detail.memberCount} membre${detail.memberCount > 1 ? 's' : ''}`;
const percent = detail.itemCount ? Math.round((detail.groupWatchedCount / detail.itemCount) * 100) : 0;
(root.querySelector('#staticProgressFill') as HTMLElement).style.width = `${percent}%`;
root.querySelector('#staticNext')!.innerHTML = detail.nextItemName
? `Prochain : <strong>${escapeHtml(detail.nextItemName)}</strong>`
: '<strong>Le groupe a tout vu.</strong>';
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
sessionBtn.textContent = detail.livePartyId ? 'Rejoindre la séance en cours' : 'Lancer la séance';
sessionBtn.disabled = !detail.livePartyId && !detail.nextItemName;
root.querySelector('#joinBtn')!.classList.toggle('hide', detail.youAreMember);
root.querySelector('#leaveBtn')!.classList.toggle('hide', !detail.youAreMember || detail.youAreCreator);
root.querySelector('#deleteBtn')!.classList.toggle('hide', !detail.youAreCreator);
renderMissed(root, detail);
renderMembers(root, detail);
renderItems(root, detail);
}
/** "Tu as manqué" - hidden entirely when there's nothing to catch up on. */
function renderMissed(root: HTMLElement, detail: StaticPartyDetailDto): void {
const section = root.querySelector('#missedSection') as HTMLElement;
const missed = new Set(detail.missedItemIds);
const items = detail.items.filter((i) => missed.has(i.itemId));
section.hidden = !items.length;
root.querySelector('#missedCount')!.textContent = String(items.length);
root.querySelector('#missedList')!.innerHTML = items.map((item) => `
<div class="episodeRow">
<input type="checkbox" class="staticCheck" data-item-id="${escapeHtml(item.itemId)}" checked aria-label="Inclure dans le rattrapage" />
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo"><strong>${escapeHtml(item.itemName)}</strong></div>
<div class="staticRowActions">
<button class="secondaryBtn soloOneBtn" data-item-id="${escapeHtml(item.itemId)}">Regarder en solo</button>
<button class="secondaryBtn markWatchedBtn" data-item-id="${escapeHtml(item.itemId)}">Marquer comme vu</button>
</div>
</div>`).join('');
}
/** One chip per member, with how many items they've watched. */
function renderMembers(root: HTMLElement, detail: StaticPartyDetailDto): void {
root.querySelector('#memberList')!.innerHTML = detail.members.length
? detail.members.map((m) => `
<span class="staticMember">${escapeHtml(m.userName)}${m.isCreator ? ' ★' : ''}<span class="hint">${m.watchedCount}/${detail.itemCount}</span></span>`).join('')
: '<p class="hint">Aucun membre pour le moment.</p>';
}
/** The whole item list, with the group's state and the viewer's own. */
function renderItems(root: HTMLElement, detail: StaticPartyDetailDto): void {
root.querySelector('#itemList')!.innerHTML = detail.items.map((item, i) => `
<div class="episodeRow${item.groupState === 'watched' ? ' isWatched' : ''}${item.groupState === 'next' ? ' isNext' : ''}">
<img src="${escapeHtml(item.itemImageUrl)}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo"><strong>${i + 1}. ${escapeHtml(item.itemName)}</strong></div>
<span class="staticState">${stateLabel(item)}${item.viewerWatched ? '<span class="mine">✓ vu par toi</span>' : ''}</span>
</div>`).join('');
}
/** The group-state label for one item. */
function stateLabel(item: StaticPartyItemDto): string {
if (item.groupState === 'watched') return 'Vu par le groupe';
if (item.groupState === 'next') return 'Prochain';
return 'À venir';
}
/**
* Wires every button once - lists are re-rendered on reload, so row
* buttons use one delegated listener on their container rather than
* per-row listeners re-added on every render.
*/
function wireActions(root: HTMLElement, staticId: string): void {
const sessionBtn = root.querySelector('#sessionBtn') as HTMLButtonElement;
sessionBtn.addEventListener('click', () => {
runBusy(sessionBtn, async () => {
const { partyId } = await startStaticSession(staticId);
navigate(`/party/${partyId}`);
});
});
const joinBtn = root.querySelector('#joinBtn') as HTMLButtonElement;
joinBtn.addEventListener('click', () => {
runBusy(joinBtn, async () => {
await joinStaticParty(staticId);
showToast('Tu fais maintenant partie de cette watch party.');
await reload(root, staticId);
});
});
const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement;
leaveBtn.addEventListener('click', () => {
if (!confirm('Quitter cette watch party ? Ta progression y sera oubliée.')) return;
runBusy(leaveBtn, async () => {
await leaveStaticParty(staticId);
await reload(root, staticId);
});
});
const deleteBtn = root.querySelector('#deleteBtn') as HTMLButtonElement;
deleteBtn.addEventListener('click', () => {
if (!confirm('Supprimer cette watch party pour tout le monde ? La progression de chacun sera perdue.')) return;
runBusy(deleteBtn, async () => {
await deleteStaticParty(staticId);
showToast('Watch party supprimée.');
navigate('/home');
});
});
const catchUp = (btn: HTMLButtonElement, mode: 'solo' | 'party') => {
const itemIds = Array.from(root.querySelectorAll<HTMLInputElement>('#missedList .staticCheck'))
.filter((c) => c.checked)
.map((c) => c.dataset.itemId as string);
if (!itemIds.length) {
showToast('Coche au moins un élément à rattraper.', { variant: 'error' });
return;
}
runBusy(btn, async () => {
const { partyId } = await startCatchUp(staticId, itemIds, mode);
navigate(`/party/${partyId}`);
});
};
const soloBtn = root.querySelector('#catchUpSoloBtn') as HTMLButtonElement;
const partyBtn = root.querySelector('#catchUpPartyBtn') as HTMLButtonElement;
soloBtn.addEventListener('click', () => catchUp(soloBtn, 'solo'));
partyBtn.addEventListener('click', () => catchUp(partyBtn, 'party'));
root.querySelector('#missedList')!.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('button[data-item-id]');
if (!btn) return;
const itemId = btn.dataset.itemId as string;
if (btn.classList.contains('soloOneBtn')) {
runBusy(btn, async () => {
const { partyId } = await startCatchUp(staticId, [itemId], 'solo');
navigate(`/party/${partyId}`);
});
} else if (btn.classList.contains('markWatchedBtn')) {
runBusy(btn, async () => {
await markStaticItemWatched(staticId, itemId);
await reload(root, staticId);
});
}
});
}
/** Disables `btn` while `action` runs; a failure is toasted, logged, and
* gives the button back. */
function runBusy(btn: HTMLButtonElement, action: () => Promise<void>): void {
btn.disabled = true;
action()
.catch((err: unknown) => {
console.error('[staticParty] action failed', err);
showToast((err as Error).message, { variant: 'error' });
})
.finally(() => {
btn.disabled = false;
});
}