watchparty/client/src/tiles.ts
Nicolas 5be839d2f6
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 13s
Music bar: refresh vote count on join/leave, progress bar; catalog search
Four smaller fixes from real usage:

- MusicBar.ts: "votes needed" for skip depends on the connected-member
  count (renderMembers/"members") AND the vote tally (applyMusicState/
  "queueUpdated") - the label was only ever recomputed from the latter,
  so a plain join/leave with no vote left it stale. Cached the last
  MusicPublicState and factored the label math into its own
  updateVoteSkipLabel(), called from both handlers now.
- Home.ts: the listen party this tab is already in still showed
  "Rejoindre" on the home page. Checks each card's partyId against
  musicBar/MusicBar.ts's own getActiveMusicPartyId() (already tracked
  client-side) and swaps the button for an inert "En écoute" label on
  that one card. No equivalent case for video - its player is a
  full-screen takeover, so you can't be looking at this list from inside
  one.
- MusicBar.html/.css/.ts: a read-only progress bar (a thin strip along
  the bar's own top edge). Deliberately not seekable - no host to
  arbitrate a jump in this model, and MusicQueueService has no "seek"
  concept server-side today; that would be a real protocol addition, out
  of scope here. Fill width is just currentTime/duration, ticked by the
  same interval that already updates the position text.
- tiles.ts (new wireSearchFilter), views/library, views/artist,
  views/album: a search input per catalog page, filtering whatever's
  already rendered client-side (a library page tops out at a few hundred
  items - well within "just filter the DOM" territory, no server round
  trip per keystroke). Shared helper so the three pages behave
  identically. The existing /api/catalog/search endpoint (video types
  only, never wired into any UI) is a separate, unrelated feature - a
  global cross-library search - left as-is.

Verified against the real compiled bundle: a "members" push alone
updates the skip label; the home page's own party shows "En écoute" while
others still show "Rejoindre"; the progress fill tracks a simulated
currentTime correctly; typing into a library's search box filters tiles
live with the empty-state notice appearing only when nothing matches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 16:44:23 +02:00

118 lines
5.4 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' }
};
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`.
* `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.
*/
export function wireTileButtons(container: ParentNode, onStart: (itemId: string) => Promise<void>): void {
container.querySelectorAll<HTMLButtonElement>('.startPartyBtn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
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';
}
});
});
}
/**
* 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);
});
}