// 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 { showToast } from './toast'; 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 = { 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' } }; /** * A poster tile - movies/shows/musicvideos/playlists, anything that * still gets Emby's own portrait artwork. MusicArtist/MusicAlbum get a * completely different shape below (a circle/a square, no hover * overlay) since a movie poster's proportions and "hover for actions" * pattern never fit an artist photo or an album cover - see * docs/architecture.md's "Music UI rework". */ function posterTileHtml(item: ItemDto): string { const drill = item.type ? DRILL_DOWN[item.type] : undefined; const drillHref = drill ? `#/${drill.route}/${item.id}` : null; // Video -> "watch party", audio -> "listen party" - see the party kinds // in docs/architecture.md (the third, "lounge party", is the static one). const partyLabel = item.type === 'Audio' ? 'Lancer une listen party' : 'Lancer une watch party'; return `
${drillHref ? `` : ''}
${item.canWatchParty ? `` : ''} ${drillHref ? `${drill!.label}` : ''} Voir sur Emby
${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}
`; } /** * An artist tile - a circle, the one shape every music app uses for a * person/act, never a rectangle. No hover overlay at all: unlike a * poster tile, there's only ever one thing to do with an artist photo * (open the artist page), so the whole tile is just that one link - * see DRILL_DOWN's own MusicArtist entry. */ function artistTileHtml(item: ItemDto): string { return ` ${escapeHtml(item.name)} `; } /** An album tile - square, matching the cover art's own real proportions * (a movie-poster 2:3 box only ever stretched/cropped it oddly). Same * "whole tile is the one link" reasoning as artistTileHtml above. */ function albumTileHtml(item: ItemDto): string { return ` ${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''} `; } /** * Renders one item as a tile - the shape depends entirely on its Emby * type. Dispatching here, rather than making every call site * (home carousels, views/library, views/artist) pick a renderer itself, * is what keeps them all visually/behaviorally identical instead of * drifting - same reasoning this module's own header comment already * gives for being shared in the first place. */ export function tileHtml(item: ItemDto): string { if (item.type === 'MusicArtist') return artistTileHtml(item); if (item.type === 'MusicAlbum') return albumTileHtml(item); return posterTileHtml(item); } /** * Wires up every "Lancer une watch/listen 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 { container.addEventListener('click', async (e) => { const btn = (e.target as HTMLElement).closest('.startPartyBtn'); if (!btn || btn.disabled) return; e.preventDefault(); const idleLabel = btn.textContent; // "watch party" or "listen party" - put back as-is on failure btn.disabled = true; btn.textContent = 'Démarrage...'; try { await onStart(btn.dataset.itemId as string); } catch (err) { showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; btn.textContent = idleLabel; } }); } /** * Renders `items` into `container` a chunk at a time instead of one * `innerHTML` write for the whole list - a library grid can legitimately * hand back over a thousand tiles now that CatalogService.getLibraryItems/ * getArtists no longer cap themselves (confirmed against the real server: * one library alone has 1229 artists - see their own comments), and * building + inserting/laying out that many 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. * * Scheduled with `setTimeout(fn, 0)`, not `requestAnimationFrame` - rAF * callbacks are deliberately paused by the browser for a backgrounded/ * hidden tab, which is a completely ordinary thing to do while a large * library is still loading (switching tabs, minimizing the window) and * would otherwise stall the render indefinitely, looking exactly like * "some of the data is missing" rather than "still loading". setTimeout * keeps running in a background tab (only throttled, never paused), so * this always finishes on its own. * * 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) setTimeout(renderNextChunk, 0); } renderNextChunk(); } /** * Wires a search 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: even a library with well over a thousand items * (see getLibraryItems/getArtists - no Limit any more, see their own * comments) is still just a plain, already-in-the-DOM list of elements * to a querySelectorAll/textContent check - "just filter the DOM" scales * fine here regardless of count. 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(itemSelector).forEach((el) => { const matches = !q || (el.textContent || '').toLowerCase().includes(q); el.classList.toggle('hide', !matches); if (matches) anyVisible = true; }); const emptyNotice = scope.querySelector('.searchEmpty'); if (emptyNotice) emptyNotice.classList.toggle('hide', anyVisible || !q); }); }