Redesign home page: library tabs, poster tiles, ongoing parties list
All checks were successful
Publier l'image Docker / publish (push) Successful in 7s
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>
This commit is contained in:
parent
b6818655b1
commit
07613c1cec
11 changed files with 429 additions and 130 deletions
|
|
@ -29,21 +29,23 @@ body {
|
|||
|
||||
#app { min-height: 100%; }
|
||||
|
||||
/* Login is a small centered form - everything else (the home page) is
|
||||
full-width, see .home below. */
|
||||
.wrap {
|
||||
max-width: 480px;
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 80px;
|
||||
}
|
||||
|
||||
.wrap.wide { max-width: 900px; }
|
||||
|
||||
h1 { font-size: 22px; margin: 0; }
|
||||
h2 { font-size: 16px; margin: 0 0 8px; }
|
||||
h2 { font-size: 16px; margin: 0 0 12px; }
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
|
|
@ -80,8 +82,6 @@ input:focus {
|
|||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input[type="search"] { margin-bottom: 20px; }
|
||||
|
||||
button {
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
|
|
@ -94,6 +94,7 @@ button {
|
|||
}
|
||||
|
||||
button:hover { background: var(--accent-bright); }
|
||||
button[disabled] { opacity: .5; cursor: default; }
|
||||
|
||||
button.linklike {
|
||||
background: none;
|
||||
|
|
@ -106,46 +107,177 @@ button.linklike {
|
|||
|
||||
button.linklike:hover { background: none; color: var(--accent-bright); }
|
||||
|
||||
.or { text-align: center; color: var(--muted); font-size: 12px; margin: 16px 0; }
|
||||
.code { color: var(--accent-bright); letter-spacing: .1em; }
|
||||
|
||||
/* ---- Home page: full width, responsive ---- */
|
||||
|
||||
.home {
|
||||
width: 100%;
|
||||
padding: 24px clamp(16px, 4vw, 48px) 80px;
|
||||
}
|
||||
|
||||
.section { margin-bottom: 32px; }
|
||||
|
||||
.joinSection {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.joinSection label { margin-bottom: 0; min-width: 160px; }
|
||||
.joinSection .error { width: 100%; }
|
||||
|
||||
.partiesList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.partyCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-header);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.partyCard img {
|
||||
width: 48px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partyCard img.noImage { visibility: hidden; }
|
||||
|
||||
.partyCardInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.partyCardInfo strong {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.partyCard button { flex-shrink: 0; padding: 8px 14px; font-size: 13px; }
|
||||
|
||||
/* Tabs: one per Emby library. Horizontal scroll on narrow screens instead
|
||||
of wrapping, so it stays a single, obviously-swipeable row. */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid rgba(255,255,255,.1);
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
padding: 8px 4px;
|
||||
border-radius: 0;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab:hover { background: none; color: var(--text); }
|
||||
.tab.active { color: var(--accent-bright); border-bottom-color: var(--accent-bright); }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 20px 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
.tile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
color: var(--text);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card img {
|
||||
.tile img {
|
||||
width: 100%;
|
||||
aspect-ratio: 2 / 3;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.detail {
|
||||
.tile img.noImage {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.tileLabel {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hover-reveal overlay with the two actions, on devices that actually have
|
||||
hover (mouse/trackpad). Touch devices get an always-visible bar instead -
|
||||
see the (hover: none) block below, since :hover is unreachable there. */
|
||||
.tileOverlay {
|
||||
position: absolute;
|
||||
inset: 0 0 26px 0; /* stop above .tileLabel */
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail img {
|
||||
width: 160px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
background: rgba(0, 0, 0, .55);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
background: var(--panel);
|
||||
opacity: 0;
|
||||
transition: opacity .15s ease;
|
||||
}
|
||||
|
||||
.detail button { width: 100%; margin-top: 8px; }
|
||||
.tile:hover .tileOverlay,
|
||||
.tile:focus-within .tileOverlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tileBtn {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tileBtnLink {
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tileBtnLink:hover { background: #333; }
|
||||
|
||||
@media (hover: none) {
|
||||
.tileOverlay {
|
||||
opacity: 1;
|
||||
inset: auto 0 26px 0;
|
||||
flex-direction: row;
|
||||
background: linear-gradient(to top, rgba(0,0,0,.75), transparent);
|
||||
padding: 6px;
|
||||
}
|
||||
.tileBtn { font-size: 11px; padding: 6px 4px; }
|
||||
}
|
||||
|
||||
/* ---- Player ---- */
|
||||
|
||||
.playerWrap {
|
||||
position: relative;
|
||||
|
|
@ -172,8 +304,10 @@ button.linklike:hover { background: none; color: var(--accent-bright); }
|
|||
background: linear-gradient(to bottom, rgba(0,0,0,.7), transparent);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.overlay .itemTitle { font-weight: 600; }
|
||||
.overlay .members { color: var(--muted); flex: 1; }
|
||||
|
||||
.followerNotice {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { route, start, navigate } from './router.js';
|
|||
import { auth, restoreSession } from './embyApi.js';
|
||||
import { renderLogin } from './views/login.js';
|
||||
import { renderBrowse } from './views/browse.js';
|
||||
import { renderItemDetail } from './views/itemDetail.js';
|
||||
import { renderPartyPlayer } from './views/player.js';
|
||||
|
||||
function requireAuth(renderFn) {
|
||||
|
|
@ -18,7 +17,6 @@ function requireAuth(renderFn) {
|
|||
route('/', () => navigate('/browse'));
|
||||
route('/login', renderLogin);
|
||||
route('/browse', requireAuth(renderBrowse));
|
||||
route('/item/:id', requireAuth(renderItemDetail));
|
||||
route('/party/:id', requireAuth(renderPartyPlayer));
|
||||
|
||||
start(document.getElementById('app'));
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ export async function getItem(itemId) {
|
|||
return apiFetch(`/api/catalog/item/${itemId}`);
|
||||
}
|
||||
|
||||
export async function getLibraries() {
|
||||
const data = await apiFetch('/api/catalog/libraries');
|
||||
return data.libraries || [];
|
||||
}
|
||||
|
||||
export async function getLibraryItems(libraryId) {
|
||||
const data = await apiFetch(`/api/catalog/libraries/${libraryId}/items`);
|
||||
return data.items || [];
|
||||
}
|
||||
|
||||
export function streamUrl(itemId) {
|
||||
return `/api/catalog/stream/${itemId}/master.m3u8`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ async function apiFetch(path, options = {}) {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
export async function listActiveParties() {
|
||||
const data = await apiFetch('/api/parties');
|
||||
return data.parties || [];
|
||||
}
|
||||
|
||||
export function createParty(itemId) {
|
||||
return apiFetch('/api/parties', { method: 'POST', body: JSON.stringify({ itemId }) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,30 @@
|
|||
import { searchItems, auth, logout } from '../embyApi.js';
|
||||
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="wrap wide">
|
||||
<main class="home">
|
||||
<div class="topbar">
|
||||
<h1>Watch Party</h1>
|
||||
<button class="linklike" id="logout">Se déconnecter (${escapeHtml(auth.userName || '')})</button>
|
||||
</div>
|
||||
<input id="search" type="search" placeholder="Cherche un film, une série, un épisode..." autofocus />
|
||||
<div id="results" class="grid"></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 () => {
|
||||
|
|
@ -17,39 +32,119 @@ export function renderBrowse(root) {
|
|||
navigate('/login');
|
||||
});
|
||||
|
||||
const input = root.querySelector('#search');
|
||||
const results = root.querySelector('#results');
|
||||
let debounceTimer;
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(runSearch, 300);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
async function runSearch() {
|
||||
const query = input.value;
|
||||
if (!query || query.trim().length < 2) {
|
||||
results.innerHTML = '';
|
||||
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 searchItems(query);
|
||||
items = await getLibraryItems(libraryId);
|
||||
} catch (err) {
|
||||
results.innerHTML = '<p class="error">Recherche impossible - le serveur Emby est-il joignable ?</p>';
|
||||
contentEl.innerHTML = '<p class="error">Impossible de charger cette bibliothèque.</p>';
|
||||
return;
|
||||
}
|
||||
if (!items.length) {
|
||||
results.innerHTML = '<p class="hint">Aucun résultat.</p>';
|
||||
contentEl.innerHTML = '<p class="hint">Bibliothèque vide.</p>';
|
||||
return;
|
||||
}
|
||||
results.innerHTML = items.map((item) => `
|
||||
<a class="card" href="#/item/${item.id}">
|
||||
<img src="${item.imageUrl}" alt="" loading="lazy" />
|
||||
<span>${escapeHtml(item.name)}${item.year ? ' (' + item.year + ')' : ''}</span>
|
||||
</a>
|
||||
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import { getItem } from '../embyApi.js';
|
||||
import { createParty, joinParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
export function renderItemDetail(root, params) {
|
||||
root.innerHTML = '<main class="wrap"><p class="hint">Chargement...</p></main>';
|
||||
|
||||
getItem(params.id).then((item) => {
|
||||
root.innerHTML = `
|
||||
<main class="wrap">
|
||||
<button class="linklike" id="back">← Retour</button>
|
||||
<div class="detail">
|
||||
<img src="${item.imageUrl}" alt="" />
|
||||
<div>
|
||||
<h1>${escapeHtml(item.name)}</h1>
|
||||
<p>${escapeHtml(item.overview || '')}</p>
|
||||
|
||||
<button id="createBtn">Démarrer une watch party</button>
|
||||
<div class="or">ou</div>
|
||||
<label>Code de la party
|
||||
<input id="joinCode" maxlength="6" style="text-transform:uppercase" />
|
||||
</label>
|
||||
<button id="joinBtn">Rejoindre</button>
|
||||
<p id="error" class="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
</main>`;
|
||||
|
||||
root.querySelector('#back').addEventListener('click', () => history.back());
|
||||
|
||||
root.querySelector('#createBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
const party = await createParty(item.id);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
} catch (err) {
|
||||
root.querySelector('#error').textContent = err.message;
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelector('#joinBtn').addEventListener('click', async () => {
|
||||
const code = root.querySelector('#joinCode').value.trim();
|
||||
try {
|
||||
const party = await joinParty(code);
|
||||
navigate(`/party/${party.partyId}`);
|
||||
} catch (err) {
|
||||
root.querySelector('#error').textContent = err.message;
|
||||
}
|
||||
});
|
||||
}).catch(() => {
|
||||
root.innerHTML = '<main class="wrap"><p class="error">Titre introuvable.</p></main>';
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { getItem, streamUrl } from '../embyApi.js';
|
||||
import { streamUrl } from '../embyApi.js';
|
||||
import { getParty, leaveParty, connectParty } from '../party.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ export function renderPartyPlayer(root, params) {
|
|||
<div class="playerWrap">
|
||||
<video id="video" playsinline></video>
|
||||
<div class="overlay">
|
||||
<span id="itemTitle" class="itemTitle"></span>
|
||||
<span>Code: <span id="code" class="code"></span></span>
|
||||
<span id="members" class="members"></span>
|
||||
<button id="leaveBtn" class="linklike">Quitter</button>
|
||||
|
|
@ -20,6 +21,7 @@ export function renderPartyPlayer(root, params) {
|
|||
</div>`;
|
||||
|
||||
const video = root.querySelector('#video');
|
||||
const itemTitleEl = root.querySelector('#itemTitle');
|
||||
const codeEl = root.querySelector('#code');
|
||||
const membersEl = root.querySelector('#members');
|
||||
const followerNotice = root.querySelector('#followerNotice');
|
||||
|
|
@ -56,12 +58,12 @@ export function renderPartyPlayer(root, params) {
|
|||
}
|
||||
|
||||
isHost = party.youAreHost;
|
||||
itemTitleEl.textContent = party.itemName || '';
|
||||
codeEl.textContent = party.joinCode;
|
||||
video.controls = isHost;
|
||||
followerNotice.classList.toggle('hide', isHost);
|
||||
|
||||
const item = await getItem(party.itemId);
|
||||
const src = streamUrl(item.id);
|
||||
const src = streamUrl(party.itemId);
|
||||
if (window.Hls && window.Hls.isSupported()) {
|
||||
const hls = new window.Hls();
|
||||
hls.loadSource(src);
|
||||
|
|
|
|||
|
|
@ -162,9 +162,52 @@ point is confirmed:
|
|||
reasoned about, never run against a playlist Emby actually generated. If
|
||||
video loads the manifest but segments 404 or hang, start here.
|
||||
- **Response field names** (`AccessToken`, `User.Id`, `User.Name`,
|
||||
`Items[].Id/Name/Overview/ProductionYear`) — long-standing Emby/Jellyfin
|
||||
convention, not independently re-confirmed for this Emby version.
|
||||
`ServerId`, `Items[].Id/Name/Overview/ProductionYear/Type`,
|
||||
`/Users/{id}/Views` returning library folders the same shape as regular
|
||||
items) — long-standing Emby/Jellyfin convention, not independently
|
||||
re-confirmed for this Emby version.
|
||||
- **`X-Emby-Authorization` header format** — same caveat as above.
|
||||
- **The Emby web client deep-link shape**
|
||||
(`/web/index.html#!/item?id=X&serverId=Y`) used for "Voir sur Emby" —
|
||||
matches the long-standing convention, not confirmed against this exact
|
||||
build's routing.
|
||||
|
||||
## Home page: library tabs, tiles, ongoing parties
|
||||
|
||||
`client/js/views/browse.js` (the home page, route `/browse`) is now:
|
||||
|
||||
- **A "watch parties en cours" section** listing every active party
|
||||
server-wide (`GET /api/parties` — deliberately not filtered to "your"
|
||||
parties, since the point is letting people discover and join one without
|
||||
needing a code passed to them separately), each with a direct "Rejoindre".
|
||||
- **A join-by-code fallback** below it, for a party that isn't visible there
|
||||
for whatever reason.
|
||||
- **One tab per Emby library** (`GET /api/catalog/libraries`, i.e. Emby's
|
||||
`/Users/{id}/Views`), each showing that library's top-level items
|
||||
(`GET /api/catalog/libraries/:id/items`, `Recursive=false` — Movies as
|
||||
movies, TV shows as Series tiles, not every episode flattened together).
|
||||
- **Tiles**: poster + name, with a hover-revealed overlay carrying two
|
||||
actions - "Lancer une watch party" (only for directly-playable types:
|
||||
Movie/Episode/Video — a Series can't be streamed as-is, so it only gets
|
||||
the second action) and "Voir sur Emby" (opens Emby's own web client to
|
||||
that item in a new tab, via a `embyUrl` the backend builds from
|
||||
`EMBY_SERVER_URL` + the session's `serverId`, captured at login).
|
||||
`:hover`/`:focus-within` reveal the overlay on mouse/keyboard; a
|
||||
`@media (hover: none)` fallback keeps it always visible (smaller, inline)
|
||||
on touch devices, which have no hover state to trigger it at all.
|
||||
- **Full width, no max-width cap** (`.home`), with a plain CSS grid
|
||||
(`repeat(auto-fill, minmax(160px, 1fr))`) doing all the responsive
|
||||
reflowing — no breakpoints needed.
|
||||
|
||||
Confirmed by actually driving a browser against this (see session history):
|
||||
tab switching swaps grid content correctly, the hover overlay shows/hides
|
||||
per-tile (not globally), a Series tile correctly omits the watch-party
|
||||
button, and the touch fallback (`(hover: none)`, tested via mobile viewport
|
||||
emulation) shows both buttons inline without needing hover at all. That
|
||||
test used a mocked `fetch` (canned JSON standing in for `/api/parties` and
|
||||
`/api/catalog/...`) since exercising it for real still needs a logged-in
|
||||
session — so the rendering/interaction logic is verified, but the real Emby
|
||||
response shapes for `/Views` and library items are not (see below).
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ async function login(username, password) {
|
|||
embyToken: data.AccessToken,
|
||||
userId: data.User.Id,
|
||||
userName: data.User.Name,
|
||||
serverId: data.ServerId, // needed to build "open in Emby" deep links
|
||||
createdAt: Date.now()
|
||||
});
|
||||
return sessionId;
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ app.get('/api/catalog/search', requireSession, async (req, res) => {
|
|||
return;
|
||||
}
|
||||
const data = await embyRes.json();
|
||||
res.json({ items: (data.Items || []).map(toItemDto) });
|
||||
res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) });
|
||||
});
|
||||
|
||||
app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => {
|
||||
|
|
@ -108,7 +108,36 @@ app.get('/api/catalog/item/:itemId', requireSession, async (req, res) => {
|
|||
res.status(embyRes.status).json({ error: 'Titre introuvable.' });
|
||||
return;
|
||||
}
|
||||
res.json(toItemDto(await embyRes.json()));
|
||||
res.json(toItemDto(await embyRes.json(), req.embySession.serverId));
|
||||
});
|
||||
|
||||
// One tab per library, e.g. Films / Séries - see client/js/views/browse.js.
|
||||
app.get('/api/catalog/libraries', requireSession, async (req, res) => {
|
||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Views`, req.embySession.embyToken);
|
||||
if (!embyRes.ok) {
|
||||
res.status(502).json({ error: 'Impossible de récupérer les bibliothèques.' });
|
||||
return;
|
||||
}
|
||||
const data = await embyRes.json();
|
||||
res.json({ libraries: (data.Items || []).map((v) => ({ id: v.Id, name: v.Name })) });
|
||||
});
|
||||
|
||||
app.get('/api/catalog/libraries/:libraryId/items', requireSession, async (req, res) => {
|
||||
const params = new URLSearchParams({
|
||||
ParentId: req.params.libraryId,
|
||||
Recursive: 'false', // top-level: Movies as movies, TV as Series tiles (not every episode)
|
||||
SortBy: 'SortName',
|
||||
Fields: 'Overview,ProductionYear',
|
||||
ImageTypeLimit: '1',
|
||||
Limit: '300'
|
||||
});
|
||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items?${params}`, req.embySession.embyToken);
|
||||
if (!embyRes.ok) {
|
||||
res.status(502).json({ error: 'Impossible de récupérer le contenu de cette bibliothèque.' });
|
||||
return;
|
||||
}
|
||||
const data = await embyRes.json();
|
||||
res.json({ items: (data.Items || []).map((i) => toItemDto(i, req.embySession.serverId)) });
|
||||
});
|
||||
|
||||
// Images/video are requested by <img>/<video> elements, which send the
|
||||
|
|
@ -150,25 +179,53 @@ app.get('/api/catalog/stream/:itemId/raw', async (req, res) => {
|
|||
await proxyEmbyResponse(req, res, pathAndQuery, session.embyToken, itemId);
|
||||
});
|
||||
|
||||
function toItemDto(item) {
|
||||
const WATCH_PARTY_TYPES = new Set(['Movie', 'Episode', 'Video']);
|
||||
|
||||
function toItemDto(item, serverId) {
|
||||
return {
|
||||
id: item.Id,
|
||||
name: item.Name,
|
||||
overview: item.Overview,
|
||||
year: item.ProductionYear,
|
||||
imageUrl: `/api/catalog/image/${item.Id}`
|
||||
type: item.Type,
|
||||
// Series/Season aren't directly playable - only show "start a party" for
|
||||
// things Emby can actually stream. "Voir sur Emby" always works though.
|
||||
canWatchParty: WATCH_PARTY_TYPES.has(item.Type),
|
||||
imageUrl: `/api/catalog/image/${item.Id}`,
|
||||
embyUrl: `${EMBY_SERVER_URL}/web/index.html#!/item?id=${item.Id}&serverId=${encodeURIComponent(serverId || '')}`
|
||||
};
|
||||
}
|
||||
|
||||
// --- Parties ---------------------------------------------------------------
|
||||
|
||||
app.post('/api/parties', requireSession, (req, res) => {
|
||||
app.get('/api/parties', requireSession, (req, res) => {
|
||||
// Everyone on the server sees every active party here - that's the point:
|
||||
// it's how the home page's "watch parties en cours" list works, so people
|
||||
// can join without needing a code passed to them out of band.
|
||||
res.json({ parties: PartyManager.listActive().map((p) => PartyManager.toPublicState(p, req.embySession.userId)) });
|
||||
});
|
||||
|
||||
app.post('/api/parties', requireSession, async (req, res) => {
|
||||
const itemId = req.body && req.body.itemId;
|
||||
if (!itemId) {
|
||||
res.status(400).json({ error: 'itemId is required.' });
|
||||
return;
|
||||
}
|
||||
const party = PartyManager.create(req.embySession.userId, req.embySession.userName, itemId);
|
||||
|
||||
const embyRes = await embyFetch(`/Users/${req.embySession.userId}/Items/${itemId}`, req.embySession.embyToken);
|
||||
if (!embyRes.ok) {
|
||||
res.status(404).json({ error: 'Titre introuvable.' });
|
||||
return;
|
||||
}
|
||||
const item = await embyRes.json();
|
||||
|
||||
const party = PartyManager.create(
|
||||
req.embySession.userId,
|
||||
req.embySession.userName,
|
||||
itemId,
|
||||
item.Name,
|
||||
`/api/catalog/image/${itemId}`
|
||||
);
|
||||
res.json(PartyManager.toPublicState(party, req.embySession.userId));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ class PartyManager {
|
|||
this.partyIdByJoinCode = new Map();
|
||||
}
|
||||
|
||||
create(hostUserId, hostUserName, itemId) {
|
||||
create(hostUserId, hostUserName, itemId, itemName, itemImageUrl) {
|
||||
const party = {
|
||||
id: crypto.randomUUID(),
|
||||
joinCode: generateJoinCode(),
|
||||
itemId,
|
||||
itemName,
|
||||
itemImageUrl,
|
||||
hostUserId,
|
||||
createdUtc: Date.now(),
|
||||
lastActivityUtc: Date.now(),
|
||||
|
|
@ -79,6 +81,11 @@ class PartyManager {
|
|||
}
|
||||
}
|
||||
|
||||
/** All active parties - powers the "watch parties en cours" home section. */
|
||||
listActive() {
|
||||
return Array.from(this.partiesById.values());
|
||||
}
|
||||
|
||||
removeIdleParties(idleTimeoutMs) {
|
||||
const cutoff = Date.now() - idleTimeoutMs;
|
||||
for (const party of this.partiesById.values()) {
|
||||
|
|
@ -96,10 +103,15 @@ class PartyManager {
|
|||
* an id to compare with at all.
|
||||
*/
|
||||
toPublicState(party, viewerUserId) {
|
||||
const host = party.members.get(party.hostUserId);
|
||||
return {
|
||||
partyId: party.id,
|
||||
joinCode: party.joinCode,
|
||||
itemId: party.itemId,
|
||||
itemName: party.itemName,
|
||||
itemImageUrl: party.itemImageUrl,
|
||||
hostUserName: host ? host.userName : null,
|
||||
memberCount: party.members.size,
|
||||
youAreHost: viewerUserId != null && viewerUserId === party.hostUserId,
|
||||
members: Array.from(party.members.values()).map((m) => ({
|
||||
userId: m.userId,
|
||||
|
|
|
|||
Loading…
Reference in a new issue