import './Home.css'; import html from './Home.html'; import { getLibraries, getRecentItems } from '../../api/embyApi'; import { connectLobby, createParty, joinParty } from '../../api/party'; import { getActiveMusicPartyId } from '../../musicBar/MusicBar'; import { navigate } from '../../router'; import { escapeHtml, tileHtml, wireTileButtons } from '../../tiles'; import { LOADING_HINT } from '../../loading'; import type { LobbyPartyDto } from '../../types'; export function renderHome(root: HTMLElement): () => void { root.innerHTML = html; // Live, not a one-shot fetch: the list (and each card's host/member count) // updates the moment anything changes anywhere - see // server/src/realtime/SocketGateway.ts's "lobby" room. The elapsed-time // label is the one piece that ticks purely client-side (from // createdUtc) - no point re-pushing from the server once a second just // for a clock. const lobby = connectLobby((parties) => renderParties(root, parties)); const elapsedTimer = window.setInterval(() => updateElapsedLabels(root), 1000); loadRecentSections(root); // Called by router.ts before rendering whatever comes next - stops the // lobby socket and the ticking clock from running after you've // navigated away (see docs/architecture.md's memory-leak writeup). return function cleanup() { lobby.disconnect(); clearInterval(elapsedTimer); }; } function renderParties(root: HTMLElement, parties: LobbyPartyDto[]): void { const section = root.querySelector('#partiesSection') as HTMLElement | null; const list = root.querySelector('#partiesCarousel'); if (!section || !list) return; // navigated away mid-flight if (!parties.length) { section.hidden = true; list.innerHTML = ''; return; } // A music party this browser tab is already attached to (see // musicBar/MusicBar.ts) has nothing useful for "Rejoindre" to do - // you're already listening, right now, via the bar already on screen. // Video has no equivalent case to check: its player is a full-screen // takeover, so you can't be looking at this list while inside one - // only leaving ever brings you back here, at which point you're not a // member any more either. const myMusicPartyId = getActiveMusicPartyId(); section.hidden = false; list.innerHTML = parties.map((p) => `
${escapeHtml(p.itemName || '')} ${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''} · --:--
${p.partyId === myMusicPartyId ? 'En écoute' : ``}
`).join(''); list.querySelectorAll('.joinPartyBtn').forEach((btn) => { btn.addEventListener('click', async () => { btn.disabled = true; try { // Actually register as a member before navigating - the player's // realtime "join" is rejected server-side otherwise (you'd load // the video but never get added to the members list or sync). await joinParty(btn.dataset.partyId as string); navigate(`/party/${btn.dataset.partyId}`); } catch (err) { alert((err as Error).message); btn.disabled = false; } }); }); updateElapsedLabels(root); } const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions /** * Ticks every visible party card's "elapsed" label once a second, purely * client-side between lobby broadcasts - same "snapshot + local tick" * shape as the player's own position sync and MusicQueueService's * getElapsedSeconds(), rather than the server pushing this every second * for every party on every home-page tab. * * A video party (positionTicks present - see LobbyPartyDto) shows the * host's actual playback position, frozen while paused: this used to * just be `now - createdUtc`, which kept climbing even while the movie * itself was paused - technically "how long the party's existed", but * misread as (and reported as) the movie's own elapsed time, which it * only ever matched by coincidence, before anyone paused. A music party * (positionTicks null - there's no single "elapsed" across a rotating * queue) still falls back to that same createdUtc-based party age. */ function updateElapsedLabels(root: HTMLElement): void { const now = Date.now(); root.querySelectorAll('.elapsed').forEach((el) => { if (el.dataset.positionTicks) { const positionMs = (Number(el.dataset.positionTicks) / TICKS_PER_SECOND) * 1000; const isPaused = el.dataset.paused === '1'; const updatedUtc = Number(el.dataset.positionUpdated); const elapsedMs = isPaused ? positionMs : positionMs + (now - updatedUtc); el.textContent = formatElapsed(elapsedMs); return; } const created = Number(el.dataset.created); if (!created) return; el.textContent = formatElapsed(now - created); }); } function formatElapsed(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; const pad = (n: number) => String(n).padStart(2, '0'); return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } async function loadRecentSections(root: HTMLElement): Promise { const container = root.querySelector('#recentSections') as HTMLElement; let libraries; try { libraries = await getLibraries(); } catch { container.innerHTML = '

Impossible de charger les bibliothèques Emby.

'; return; } for (const lib of libraries) { const section = document.createElement('section'); section.className = 'section'; section.innerHTML = `

Ajouts récents - ${escapeHtml(lib.name)}

`; container.appendChild(section); getRecentItems(lib.id) .then((items) => { if (!items.length) { section.remove(); return; } const carousel = section.querySelector('.carousel')!; carousel.innerHTML = items.map(tileHtml).join(''); wireTileButtons(carousel, async (itemId) => { const party = await createParty(itemId); navigate(`/party/${party.partyId}`); }); }) .catch(() => section.remove()); } }