watchparty/client/src/tiles.ts
Nicolas b45a243e59
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 16s
Browse Emby playlists and launch one as a pre-queued party
Queued right after the ffmpeg burn-in work, asked for directly: see
what's inside a playlist, and launch it - pre-queued with the whole
thing, the same "one click, the rest queues itself" shape starting from
an episode already gets for its series.

- server/src/services/CatalogService.ts: VISIBLE_COLLECTION_TYPES gains
  'playlists' - Emby's /Views already returns that CollectionType once
  the user has any, so it appears in the sidebar through the exact same
  code every other library already uses, nothing else needed there. New
  getPlaylistItemsRaw/getPlaylistItems (Emby's own /Playlists/{id}/Items,
  mirroring getSeriesEpisodes/getAlbumTracks). New exported
  splitPlaylistByMediaType: a playlist can mix movies/episodes and songs,
  this app's party model can't - decides video vs music from the first
  item's type and drops anything that doesn't match (logged when it
  does) - pure, unit-tested on its own.
- server/src/index.ts: new GET /api/catalog/playlists/:playlistId/items
  route. POST /api/parties gains a sibling early branch for
  item.Type === 'Playlist' (a new createPartyFromPlaylist helper,
  deliberately NOT merged into the existing single-item path, which
  didn't need touching) - builds a video QueueItem[] exactly like the
  episode-queue path already does, or seeds MusicQueueService.init +
  addTrack per track for a music result, the same mechanism
  POST /api/parties/:id/tracks already uses.
- client/src/tiles.ts: one more DRILL_DOWN entry (Playlist -> its own
  page) - a playlist was never directly streamable, same as
  Series/MusicArtist/MusicAlbum before it.
- client/src/api/embyApi.ts: getPlaylistItems(). No new API for
  *launching* one - createParty(playlistId) already does everything,
  same as clicking any other item; the server decides what it means.
- client/src/views/playlist/Playlist.{ts,html,css} (new): a playlist's
  contents, read-only past the one "Lancer cette playlist" button -
  deliberately no per-item start/add the way album tracks or episode
  rows get (an Emby item carries no back-reference to "which playlist
  contains me" the way an Episode's SeriesId does, so "start from item
  N, queue the rest of the playlist" isn't cheaply reconstructable the
  same way - not asked for here anyway). The launch button branches on
  the created party's mediaType, same check Player.ts's own dispatcher
  already makes.
- client/src/app.ts: registers /playlist/:id.
- server/test/catalogService.test.ts: the new methods' target
  URL/mapping, plus splitPlaylistByMediaType's own tests (homogeneous
  video, homogeneous music, mixed, empty).
- docs/architecture.md, README.md: design + the scope decision (no
  per-item actions) spelled out.

Verified: full typecheck+test+build on both projects (62/62 server
tests). The new page driven through the same mocked-fetch + real-bundle
harness used throughout this session, in a real browser: header/meta/
item list render correctly from mocked catalog data (including the
sidebar showing "Playlists" once a mocked library declares that
CollectionType), and clicking "Lancer cette playlist" reaches
POST /api/parties with the right id and correctly routes into the video
player for a video result. Actual playback past that point still needs
a real Emby server - same standing caveat as the rest of this app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 19:04:31 +02:00

119 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' },
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`.
* `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);
});
}