- UI wording: "Watch party" = classic video, "Listen party" = classic audio, "Lounge party" = static party (tab, page, create buttons, toasts/confirms, server error messages); code names are unchanged - home: "Parties en cours" with a Watch party / Listen party badge on each card - music launch buttons say "Lancer une listen party"; a failed tile launch now restores its own label instead of always "watch party" Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
9.7 KiB
TypeScript
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 lounge 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 lounge party.');
|
|
await reload(root, staticId);
|
|
});
|
|
});
|
|
|
|
const leaveBtn = root.querySelector('#leaveBtn') as HTMLButtonElement;
|
|
leaveBtn.addEventListener('click', () => {
|
|
if (!confirm('Quitter cette lounge 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 lounge party pour tout le monde ? La progression de chacun sera perdue.')) return;
|
|
runBusy(deleteBtn, async () => {
|
|
await deleteStaticParty(staticId);
|
|
showToast('Lounge party supprimée.');
|
|
navigate('/static');
|
|
});
|
|
});
|
|
|
|
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;
|
|
});
|
|
}
|