Reported directly: the music bar's queue popup looked broken, and the
listen-party card on the home page never showed an image. Put together a
mockup first (this app's own theme tokens, not a generic one) to confirm
the redesign direction before touching any code, since the queue side
was a real UI rework, not a one-line fix.
The layout bug: MusicBar.ts's queue rows were reusing views/album's
shared .trackRow (styles/trackList.css) - a CSS grid with 4 fixed
columns sized for THAT page's own row shape (track number, name,
duration, a button). The queue never had a track number, and rendered
completely different content in that same slot, so the track's own name
- the first child - landed in the 28px column meant for a one-or-two-
digit index, crushed to its first couple of characters, while the
artist/album text filled the wide column meant for the name instead.
Fixed with a dedicated .queueRow (flex, not grid): album art, a
truncating name+artist/album/added-by info column, duration on the
right.
The missing images: confirmed directly against the real Emby server
(three sampled tracks, Fields=AlbumId,ImageTags) that an individual
Audio item essentially never carries its own Primary image - every one
tested answered 500, not even a clean 404 - while the same tracks'
parent albums mostly did. Every place that built an image URL from a
raw EmbyItem was independently reconstructing /api/catalog/image/{id}
by hand (CatalogService.toItemDto, and three separate spots in
index.ts's party-creation routes bypassing toItemDto entirely) - all
now funnel through one new CatalogService.imageUrlFor(item), which
points a track at its AlbumId instead of its own id when one's known.
EmbyItem gained AlbumId; every Fields param listing Audio items now
requests it.
While fixing the now-working image, the home page's party card turned
out to be the wrong shape for music too: a fixed 48x72 portrait box,
correct for a movie/show poster, forcing a hard crop on an album's
square cover. Video parties are untouched; a music party's card now
gets a 56x56 square box instead, toggled from LobbyPartyDto.mediaType.
Tests: imageUrlFor (movie/episode unaffected, a track with an AlbumId
falls back to it, a track with neither still falls back to its own id
rather than a broken URL). Full typecheck/test/build clean on both
projects (118/118 server tests). Verified visually against the actual
compiled app.css (not just the mockup) - injected real markup using the
new classes into a running instance and confirmed the queue rows and
both card shapes render as designed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
170 lines
6.9 KiB
TypeScript
170 lines
6.9 KiB
TypeScript
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) => `
|
|
<div class="partyCard">
|
|
<img class="${p.mediaType === 'music' ? 'partyCardArt--square' : ''}" 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}"
|
|
data-position-ticks="${p.positionTicks ?? ''}"
|
|
data-paused="${p.isPaused ? '1' : '0'}"
|
|
data-position-updated="${p.positionUpdatedUtc ?? ''}"
|
|
>--:--</span>
|
|
</span>
|
|
</div>
|
|
${p.partyId === myMusicPartyId
|
|
? '<span class="hint partyCardMine">En écoute</span>'
|
|
: `<button class="joinPartyBtn" data-party-id="${p.partyId}">Rejoindre</button>`}
|
|
</div>
|
|
`).join('');
|
|
|
|
list.querySelectorAll<HTMLButtonElement>('.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<HTMLElement>('.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<void> {
|
|
const container = root.querySelector('#recentSections') as HTMLElement;
|
|
let libraries;
|
|
try {
|
|
libraries = await getLibraries();
|
|
} catch {
|
|
container.innerHTML = '<p class="error">Impossible de charger les bibliothèques Emby.</p>';
|
|
return;
|
|
}
|
|
|
|
for (const lib of libraries) {
|
|
const section = document.createElement('section');
|
|
section.className = 'section';
|
|
section.innerHTML = `<h2>Ajouts récents - ${escapeHtml(lib.name)}</h2><div class="carousel">${LOADING_HINT}</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());
|
|
}
|
|
}
|