Three separate reports handled together. Cast was video-only by design (docs/architecture.md flagged it as video-parties-only for v1) - asked for directly, and turned out to need almost nothing new server-side: CastTokenService and the 4 streaming routes' sessionForStream fallback were already keyed on nothing more specific than partyId. cast.ts's localDriver only needed its parameter widened from HTMLVideoElement to HTMLMediaElement - both already share the same currentTime/paused/duration/play()/pause() surface. MusicBar.ts gained the same activeDriver indirection Player.ts already has, with one real difference from the video case: a listen party's queue advances far more often than a video party changes items, so dropping the cast on every track (the video path's own choice there) would have made this worse than not having it - a track change while casting instead calls startCast() again, reusing the already-connected session (a cache-busting query param added since the content URL itself never changes between tracks - the server alone decides what's current). Falls back to local playback if a re-cast throws, same recovery instinct loadTrack's own hls.js try/catch already has. The queue popup: reported separately, a fixed 480px width and a header that scrolled away with the list - both leftover CSS from before the row shape was reworked, never revisited since. Width cap dropped; overflow/max-height moved off the outer .musicBarQueue (which wraps the header too) down onto #queueList alone. The catalog: still too slow, the front freezing while it loads. CatalogService.getLibraryItems was asking Emby for Overview on every one of up to 300 items and never using it (views/library's tiles only ever show a poster/name/year) - dropped. getArtists had no Limit at all, unlike every sibling query. Separately, tiles.ts's shared render path built one HTML string for a whole batch and inserted it via a single innerHTML write - fine for a 16-item carousel, a real hitch for a 300-tile grid. renderTilesInChunks inserts the same tileHtml output 40 at a time across requestAnimationFrame callbacks instead; wireTileButtons switched to one delegated listener so a tile from a later chunk is still clickable. Verified: full typecheck/test/build on both projects (122/122 server tests). The chunked-render mechanism was verified directly - a synthetic 300-item list against a real DOM confirms every item lands and a button in the last chunk still fires through the delegated listener. Not verified: no real Chromecast or Emby login reachable from this sandbox, so the actual on-TV re-cast round trip and the before/after catalog latency against a real large library are both unconfirmed - same standing gap this project already flags for its other casting work. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
155 lines
7.2 KiB
TypeScript
155 lines
7.2 KiB
TypeScript
// Shared poster-tile rendering - used by the home carousels, the library
|
|
// grid, and the artist page, so they all stay visually/behaviorally
|
|
// identical instead of drifting.
|
|
import './tiles.css';
|
|
import type { ItemDto } from './types';
|
|
|
|
export function escapeHtml(str: string): string {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
/** "m:ss" (or "h:mm:ss" past an hour) - shared by the album track list and
|
|
* the music player, so a track's listed duration and its playing position
|
|
* always read the same way. */
|
|
export function formatDuration(totalSeconds: number): string {
|
|
const s = Math.max(0, Math.round(totalSeconds || 0));
|
|
const h = Math.floor(s / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
const sec = s % 60;
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
|
return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`;
|
|
}
|
|
|
|
// Small deterministic palette so the same person always gets the same
|
|
// avatar color across a session, without needing the server to assign one
|
|
// - shared by the video player's presence overlay and the music player's
|
|
// listener list, so the same person reads as the same color in either.
|
|
const AVATAR_COLORS = ['#52b54b', '#4c8bf5', '#f5a623', '#e05d5d', '#9b59b6', '#1abc9c', '#e67e22', '#3498db'];
|
|
|
|
export function colorForName(name: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i++) hash = (hash * 31 + name.charCodeAt(i)) >>> 0;
|
|
return AVATAR_COLORS[hash % AVATAR_COLORS.length] as string;
|
|
}
|
|
|
|
export function initialsFor(name: string): string {
|
|
return (name || '').trim().slice(0, 2).toUpperCase() || '?';
|
|
}
|
|
|
|
// Drill-down targets: a tile for a type with no direct playback (Series,
|
|
// MusicArtist, MusicAlbum) links into its own browsing page instead of
|
|
// getting a "start a party" button - canWatchParty is already false for
|
|
// all three server-side (see WATCH_PARTY_TYPES in CatalogService).
|
|
const DRILL_DOWN: Record<string, { route: string; label: string }> = {
|
|
Series: { route: 'series', label: 'Voir les épisodes' },
|
|
MusicArtist: { route: 'artist', label: 'Voir les albums' },
|
|
MusicAlbum: { route: 'album', label: 'Voir les morceaux' },
|
|
Playlist: { route: 'playlist', label: 'Voir la playlist' }
|
|
};
|
|
|
|
export function tileHtml(item: ItemDto): string {
|
|
const drill = item.type ? DRILL_DOWN[item.type] : undefined;
|
|
const drillHref = drill ? `#/${drill.route}/${item.id}` : null;
|
|
const partyLabel = item.type === 'Audio' ? 'Écouter ensemble' : 'Lancer une watch party';
|
|
return `
|
|
<div class="tile">
|
|
${drillHref ? `<a href="${drillHref}" class="tileLink" aria-label="${escapeHtml(item.name)}"></a>` : ''}
|
|
<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}">${partyLabel}</button>` : ''}
|
|
${drillHref ? `<a class="tileBtn" href="${drillHref}">${drill!.label}</a>` : ''}
|
|
<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>`;
|
|
}
|
|
|
|
/**
|
|
* Wires up every "Lancer une watch party" button inside `container`, now
|
|
* and for any tile added to it later. `onStart(itemId)` does the actual
|
|
* work (create the party + navigate) - kept as a caller-supplied callback
|
|
* so this module doesn't need to depend on api/party.ts/router.ts itself.
|
|
*
|
|
* One delegated listener on `container` itself, not one per button - a
|
|
* plain per-button `addEventListener` loop only ever catches whatever's
|
|
* already in the DOM at call time, which renderTilesInChunks (below)
|
|
* deliberately isn't: its later chunks land after this runs, and a
|
|
* direct-bound listener would silently never cover them.
|
|
*/
|
|
export function wireTileButtons(container: ParentNode, onStart: (itemId: string) => Promise<void>): void {
|
|
container.addEventListener('click', async (e) => {
|
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.startPartyBtn');
|
|
if (!btn || btn.disabled) return;
|
|
e.preventDefault();
|
|
btn.disabled = true;
|
|
btn.textContent = 'Démarrage...';
|
|
try {
|
|
await onStart(btn.dataset.itemId as string);
|
|
} catch (err) {
|
|
alert((err as Error).message);
|
|
btn.disabled = false;
|
|
btn.textContent = 'Lancer une watch party';
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Renders `items` into `container` a chunk at a time, across animation
|
|
* frames, instead of one `innerHTML` write for the whole list - a library
|
|
* grid can hand back up to 300 tiles at once (CatalogService.getLibraryItems/
|
|
* getArtists), and building + inserting/laying out ~300 posters in a
|
|
* single synchronous task is exactly what a "the page freezes while it
|
|
* loads" report looks like on a slower device. Splitting the same HTML
|
|
* across a few chunks keeps every individual task well under a frame's
|
|
* budget, so the tab stays responsive (and the loading spinner keeps
|
|
* animating) while it works through the rest.
|
|
*
|
|
* Uses `insertAdjacentHTML`, not repeated `innerHTML +=` (which would
|
|
* re-parse and re-render everything already inserted on every chunk).
|
|
* Safe to pair with wireTileButtons/wireSearchFilter called once right
|
|
* after this returns - both already tolerate tiles that land later (see
|
|
* their own comments).
|
|
*/
|
|
export function renderTilesInChunks(container: HTMLElement, items: ItemDto[], chunkSize = 40): void {
|
|
container.innerHTML = '';
|
|
let i = 0;
|
|
function renderNextChunk(): void {
|
|
const chunk = items.slice(i, i + chunkSize);
|
|
if (!chunk.length) return;
|
|
container.insertAdjacentHTML('beforeend', chunk.map(tileHtml).join(''));
|
|
i += chunkSize;
|
|
if (i < items.length) requestAnimationFrame(renderNextChunk);
|
|
}
|
|
renderNextChunk();
|
|
}
|
|
|
|
/**
|
|
* Wires a search <input> to show/hide `itemSelector` elements by name,
|
|
* live as you type - client-side only, filtering whatever's already
|
|
* rendered rather than a server round trip per keystroke. Deliberately
|
|
* simple for that reason: a library page tops out at a few hundred items
|
|
* (see getLibraryItems's own Limit), well within "just filter the DOM"
|
|
* territory - shared by views/library, views/artist, views/album so the
|
|
* three catalog pages that asked for this all behave identically instead
|
|
* of drifting into three slightly different implementations.
|
|
*
|
|
* `scope` should be wide enough to contain both the items themselves AND
|
|
* a `.searchEmpty` notice element - typically the view's own `root`, not
|
|
* just the immediate grid/list wrapper (which is usually a sibling of
|
|
* `.searchEmpty`, not an ancestor of it).
|
|
*/
|
|
export function wireSearchFilter(input: HTMLInputElement, scope: ParentNode, itemSelector: string): void {
|
|
input.addEventListener('input', () => {
|
|
const q = input.value.trim().toLowerCase();
|
|
let anyVisible = false;
|
|
scope.querySelectorAll<HTMLElement>(itemSelector).forEach((el) => {
|
|
const matches = !q || (el.textContent || '').toLowerCase().includes(q);
|
|
el.classList.toggle('hide', !matches);
|
|
if (matches) anyVisible = true;
|
|
});
|
|
const emptyNotice = scope.querySelector<HTMLElement>('.searchEmpty');
|
|
if (emptyNotice) emptyNotice.classList.toggle('hide', anyVisible || !q);
|
|
});
|
|
}
|