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 { showToast } from '../../toast';
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) => `
`).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) {
showToast((err as Error).message, { variant: 'error' });
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 = `