watchparty/client/js/views/home.js
Nicolas b6019bd211
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
Live lobby, host handoff reflected live, remove join codes, fix player scroll
Three requested changes plus one found-in-passing bug fix:

1. Home page's "watch parties en cours" is now a live Socket.IO
   subscription (connectLobby -> "lobby" room), not a one-shot fetch -
   updates the instant anything changes anywhere (create/join/leave).
   Host name + member count come from the snapshot; elapsed time ticks
   purely client-side from a createdUtc timestamp (no point pushing a
   recomputed duration every second for every viewer). home.js returns
   this subscription from its router cleanup, same leak-prevention
   pattern as the player's socket.

2. Host handoff is now live end to end, not just correct in the data
   model. PartyManager.leave already promoted another member and
   destroyed an empty party (that part predates this change) - what
   was missing was any way for an already-open follower player to find
   out it became host. applyLeave (shared by the socket leave/
   disconnect handlers and the REST leave endpoint, so this and the
   lobby broadcast only live in one place) now emits a personal
   "hostChanged" to the new host's own socket; player.js's becomeHost()
   unlocks controls live. becomeHost is written to tolerate being
   called more than once safely (addEventListener no-ops on a repeat
   identical listener; the heartbeat setInterval is explicitly guarded
   since it has no such idempotence - calling it twice would leak the
   first interval, the exact bug class the previous pass fixed).

3. No more join codes - PartyManager no longer generates or tracks
   one. The live lobby list is the only discovery mechanism now; a
   code copied out of band doesn't add anything on top of a list
   that's already always current. POST /api/parties/:partyId/join
   replaces :joinCode; the "Rejoindre avec un code" UI is gone.

Found while touching this: the home page's "Rejoindre" button used to
navigate to the player WITHOUT ever calling the join endpoint first -
it loaded fine (GET /api/parties/:id never checked membership) but the
realtime "join" was silently rejected server-side (not an actual
member), so sync never worked for anyone joining this way. Fixed to
await joinParty() before navigating, matching how the library grid's
tiles already did it.

Also: .playerWrap was width:100%/height:100vh inside .mainContent's own
padding, so the two stacked and made the page taller than the
viewport - an outer scrollbar despite the video already being full-
height. Changed to position:fixed/inset:0 so it fills the real
viewport regardless of ancestor padding.

Verified live against a real running server (see session history):
lobby subscription renders host/member-count/ticking-elapsed correctly
from a faked "parties" push (createdUtc 2:05 in the past rendered
"2:06" after ~1s more, proving the client-side tick is real, not
static); the join-then-navigate fix calls the join endpoint before
navigating; .playerWrap computes to position:fixed with all four
insets at 0.

docs/architecture.md has the full writeup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 23:47:12 +02:00

134 lines
4.6 KiB
JavaScript

import { getLibraries, getRecentItems } from '../embyApi.js';
import { connectLobby, joinParty, createParty } from '../party.js';
import { navigate } from '../router.js';
import { tileHtml, wireTileButtons, escapeHtml } from '../tiles.js';
export function renderHome(root) {
root.innerHTML = `
<div class="pageHeader">
<h1>Accueil</h1>
</div>
<section class="section" id="partiesSection" hidden>
<h2>Watch parties en cours</h2>
<div id="partiesCarousel" class="carousel"></div>
</section>
<div id="recentSections"></div>`;
// Live, not a one-shot fetch: the list (and each card's host/member count)
// updates the moment anything changes anywhere - see
// server/src/socketHandler.js'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 = setInterval(() => updateElapsedLabels(root), 1000);
loadRecentSections(root);
// Called by router.js 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, parties) {
const section = root.querySelector('#partiesSection');
const list = root.querySelector('#partiesCarousel');
if (!section || !list) return; // navigated away mid-flight
if (!parties.length) {
section.hidden = true;
list.innerHTML = '';
return;
}
section.hidden = false;
list.innerHTML = parties.map((p) => `
<div class="partyCard">
<img src="${p.itemImageUrl || ''}" alt="" onerror="this.classList.add('noImage')" />
<div class="partyCardInfo">
<strong>${escapeHtml(p.itemName || '')}</strong>
<span class="hint">
${escapeHtml(p.hostUserName || '')} · ${p.memberCount} participant${p.memberCount > 1 ? 's' : ''}
· <span class="elapsed" data-created="${p.createdUtc}">--:--</span>
</span>
</div>
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
</div>
`).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);
navigate(`/party/${btn.dataset.partyId}`);
} catch (err) {
alert(err.message);
btn.disabled = false;
}
});
});
updateElapsedLabels(root);
}
function updateElapsedLabels(root) {
const now = Date.now();
root.querySelectorAll('.elapsed').forEach((el) => {
const created = Number(el.dataset.created);
if (!created) return;
el.textContent = formatElapsed(now - created);
});
}
function formatElapsed(ms) {
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) => String(n).padStart(2, '0');
return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`;
}
async function loadRecentSections(root) {
const container = root.querySelector('#recentSections');
let libraries;
try {
libraries = await getLibraries();
} catch (err) {
container.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
return;
}
for (const lib of libraries) {
if (lib.collectionType === 'music') continue; // music gets its own experience later, not a poster row
const section = document.createElement('section');
section.className = 'section';
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel"><p class="hint">Chargement...</p></div>`;
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());
}
}