All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
- New backend endpoints: GET /api/catalog/libraries (Emby's
/Users/{id}/Views), GET /api/catalog/libraries/:id/items (top-level,
Recursive=false - movies as movies, TV as Series tiles), GET
/api/parties (list every active party server-wide, for the home
page's "watch parties en cours" section)
- Item DTO gained `type` and `canWatchParty` (only Movie/Episode/Video
are directly streamable - a Series tile only gets "Voir sur Emby")
and `embyUrl`, a deep link into Emby's own web client built from
EMBY_SERVER_URL + serverId (now captured at login)
- Party creation now fetches and stores the item's name/poster so the
home page's party list doesn't need a separate lookup per party
- client/js/views/browse.js rewritten: ongoing-parties section, a
join-by-code fallback, one tab per library, and a tile grid with a
hover-revealed two-button overlay (start party / view on Emby) - the
first only shown for playable types
- Removed itemDetail.js/the /item/:id route (tiles act directly now,
no intermediate detail page); player.js simplified to use
party.itemName instead of a redundant item lookup
- app.css: full-width home layout (no max-width cap, as asked), CSS
grid handling all the responsive reflow, :hover/:focus-within reveal
for the tile overlay with an always-visible (hover: none) fallback
for touch devices
Verified by actually driving a browser (mocked fetch standing in for
/api/parties and /api/catalog/* - real login still needs real
credentials): tab switching swaps content correctly, the overlay
reveals per-tile only, a Series tile correctly omits the watch-party
button, and the touch fallback shows both buttons inline without
hover. See docs/architecture.md for exactly what that does and doesn't
confirm (real Emby /Views and item-list response shapes are still
unverified).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
154 lines
5.1 KiB
JavaScript
154 lines
5.1 KiB
JavaScript
import { auth, logout, getLibraries, getLibraryItems } from '../embyApi.js';
|
|
import { listActiveParties, createParty, joinParty } from '../party.js';
|
|
import { navigate } from '../router.js';
|
|
|
|
export function renderBrowse(root) {
|
|
root.innerHTML = `
|
|
<main class="home">
|
|
<div class="topbar">
|
|
<h1>Watch Party</h1>
|
|
<button class="linklike" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
|
|
</div>
|
|
|
|
<section id="partiesSection" class="section hide">
|
|
<h2>Watch parties en cours</h2>
|
|
<div id="partiesList" class="partiesList"></div>
|
|
</section>
|
|
|
|
<section class="section joinSection">
|
|
<label>Rejoindre avec un code
|
|
<input id="joinCode" maxlength="6" style="text-transform:uppercase" />
|
|
</label>
|
|
<button id="joinBtn">Rejoindre</button>
|
|
<p id="joinError" class="error"></p>
|
|
</section>
|
|
|
|
<nav id="tabs" class="tabs"></nav>
|
|
<div id="tabContent" class="grid"><p class="hint">Chargement...</p></div>
|
|
</main>`;
|
|
|
|
root.querySelector('#logout').addEventListener('click', async () => {
|
|
await logout();
|
|
navigate('/login');
|
|
});
|
|
|
|
root.querySelector('#joinBtn').addEventListener('click', async () => {
|
|
const code = root.querySelector('#joinCode').value.trim();
|
|
if (!code) return;
|
|
try {
|
|
const party = await joinParty(code);
|
|
navigate(`/party/${party.partyId}`);
|
|
} catch (err) {
|
|
root.querySelector('#joinError').textContent = err.message;
|
|
}
|
|
});
|
|
|
|
loadParties(root);
|
|
loadLibraries(root);
|
|
}
|
|
|
|
async function loadParties(root) {
|
|
let parties;
|
|
try {
|
|
parties = await listActiveParties();
|
|
} catch (err) {
|
|
return; // non-critical for the page as a whole - just skip the section
|
|
}
|
|
if (!parties.length) return;
|
|
|
|
const section = root.querySelector('#partiesSection');
|
|
section.classList.remove('hide');
|
|
root.querySelector('#partiesList').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>
|
|
</div>
|
|
<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>
|
|
</div>
|
|
`).join('');
|
|
|
|
root.querySelectorAll('.joinPartyBtn').forEach((btn) => {
|
|
btn.addEventListener('click', () => navigate(`/party/${btn.dataset.partyId}`));
|
|
});
|
|
}
|
|
|
|
async function loadLibraries(root) {
|
|
const tabsEl = root.querySelector('#tabs');
|
|
const contentEl = root.querySelector('#tabContent');
|
|
|
|
let libraries;
|
|
try {
|
|
libraries = await getLibraries();
|
|
} catch (err) {
|
|
contentEl.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
|
|
return;
|
|
}
|
|
if (!libraries.length) {
|
|
contentEl.innerHTML = '<p class="hint">Aucune bibliothèque disponible sur ce compte.</p>';
|
|
return;
|
|
}
|
|
|
|
tabsEl.innerHTML = libraries
|
|
.map((lib, i) => `<button class="tab${i === 0 ? ' active' : ''}" data-library-id="${lib.id}">${escapeHtml(lib.name)}</button>`)
|
|
.join('');
|
|
|
|
tabsEl.querySelectorAll('.tab').forEach((tab) => {
|
|
tab.addEventListener('click', () => {
|
|
if (tab.classList.contains('active')) return;
|
|
tabsEl.querySelectorAll('.tab').forEach((t) => t.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
loadLibraryItems(contentEl, tab.dataset.libraryId);
|
|
});
|
|
});
|
|
|
|
loadLibraryItems(contentEl, libraries[0].id);
|
|
}
|
|
|
|
async function loadLibraryItems(contentEl, libraryId) {
|
|
contentEl.innerHTML = '<p class="hint">Chargement...</p>';
|
|
let items;
|
|
try {
|
|
items = await getLibraryItems(libraryId);
|
|
} catch (err) {
|
|
contentEl.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
contentEl.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
|
return;
|
|
}
|
|
|
|
contentEl.innerHTML = items.map((item) => `
|
|
<div class="tile">
|
|
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
|
|
<div class="tileOverlay">
|
|
${item.canWatchParty ? `<button class="tileBtn startPartyBtn" data-item-id="${item.id}">Lancer une watch party</button>` : ''}
|
|
<a class="tileBtn tileBtnLink" href="${item.embyUrl}" target="_blank" rel="noopener">Voir sur Emby</a>
|
|
</div>
|
|
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
contentEl.querySelectorAll('.startPartyBtn').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Démarrage...';
|
|
try {
|
|
const party = await createParty(btn.dataset.itemId);
|
|
navigate(`/party/${party.partyId}`);
|
|
} catch (err) {
|
|
alert(err.message);
|
|
btn.disabled = false;
|
|
btn.textContent = 'Lancer une watch party';
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|