watchparty/client/src/tiles.ts
Nicolas 58d51f5f74
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Name the three party kinds: watch, listen and lounge party
- UI wording: "Watch party" = classic video, "Listen party" = classic
  audio, "Lounge party" = static party (tab, page, create buttons,
  toasts/confirms, server error messages); code names are unchanged
- home: "Parties en cours" with a Watch party / Listen party badge on
  each card
- music launch buttons say "Lancer une listen party"; a failed tile
  launch now restores its own label instead of always "watch party"

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 19:22:15 +02:00

221 lines
10 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 { 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<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' }
};
/**
* 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 `
<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>`;
}
/**
* 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 `
<a href="#/artist/${item.id}" class="tile tileArtist" aria-label="${escapeHtml(item.name)}">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<span class="tileLabel">${escapeHtml(item.name)}</span>
</a>`;
}
/** 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 `
<a href="#/album/${item.id}" class="tile tileAlbum" aria-label="${escapeHtml(item.name)}">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<span class="tileLabel">${escapeHtml(item.name)}${item.year ? ` (${item.year})` : ''}</span>
</a>`;
}
/**
* 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>): void {
container.addEventListener('click', async (e) => {
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.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 <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: 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<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);
});
}