watchparty/client/src/views/playlist/Playlist.ts
Nicolas 939beb6fa9
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 7s
Add a spinner to every loading state
Every "Chargement..." spot across the app was plain text with no
visual motion - easy to mistake a still-fetching page for a stuck one.

New .spinner in styles/base.css - a plain CSS ring (border +
border-top-color, no image/icon-font asset), sized and vertically
aligned to drop into an existing text node inline instead of needing
a flex-row restructure. prefers-reduced-motion turns the spin off.

New client/src/loading.ts exports one shared LOADING_HINT constant so
every dynamic call site (Album/Artist/Library/Playlist/Series's own
route entry, Home's per-library carousels, a season switch reloading
its episode list, the player's route-level load) renders identical
markup. Shell.html's sidebar library list and Library.html's #grid
placeholder (both static templates, no JS import) got the same
markup pasted directly. Player.html's #loadingNotice (video
buffering) got the spinner too.

Left alone: a couple of static templates' own inert copies of this
same placeholder text (Playlist.html, Series.html) that are always
synchronously overwritten before ever painting - nothing a viewer
could ever actually see differently. Also left alone: the brief
button-label loading states elsewhere ("Ajout...", "Démarrage...") -
a different, smaller UI element, typically resolved within one REST
round trip.

Verified: full typecheck/build on the client (no server changes at
all). Visually confirmed in a real browser that the spinner renders
correctly inline with text and is genuinely animating.

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

90 lines
3.8 KiB
TypeScript

import './Playlist.css';
import html from './Playlist.html';
import { getItem, getPlaylistItems } from '../../api/embyApi';
import { createParty } from '../../api/party';
import { joinMusicParty } from '../../musicBar/MusicBar';
import { navigate } from '../../router';
import type { RouteParams } from '../../router';
import { escapeHtml, formatDuration } from '../../tiles';
import { LOADING_HINT } from '../../loading';
import type { ItemDto } from '../../types';
const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations
/**
* Route `/playlist/:id` - a playlist's own contents, read-only (see
* docs/architecture.md's "Scope" note: no per-item start/add here, only
* views/library's poster tiles and views/album's per-track rows do
* that - a playlist 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 something this page can
* reconstruct the way views/series does). The one action here is
* "Lancer cette playlist" - launches the whole thing as a party,
* pre-queued server-side (see index.ts's createPartyFromPlaylist).
*/
export async function renderPlaylist(root: HTMLElement, params: RouteParams): Promise<void> {
const playlistId = params.id as string;
root.innerHTML = LOADING_HINT;
let playlist: ItemDto;
let items: ItemDto[];
try {
[playlist, items] = await Promise.all([getItem(playlistId), getPlaylistItems(playlistId)]);
} catch {
root.innerHTML = '<p class="error">Impossible de charger cette playlist.</p>';
return;
}
root.innerHTML = html;
root.querySelector('#back')!.addEventListener('click', () => history.back());
(root.querySelector('#playlistCover') as HTMLImageElement).src = playlist.imageUrl;
root.querySelector('#playlistTitle')!.textContent = playlist.name;
root.querySelector('#playlistMeta')!.textContent = items.length
? `${items.length} élément${items.length > 1 ? 's' : ''}`
: 'Playlist vide';
(root.querySelector('#embyLink') as HTMLAnchorElement).href = playlist.embyUrl;
const listEl = root.querySelector('#itemList')!;
const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement;
if (!items.length) {
listEl.innerHTML = '<p class="hint">Cette playlist ne contient rien pour le moment.</p>';
launchBtn.disabled = true;
return;
}
listEl.innerHTML = items.map((item, i) => {
const meta = item.overview || [item.artistName, item.albumName].filter(Boolean).join(' · ');
const duration = item.durationTicks ? formatDuration(item.durationTicks / TICKS_PER_SECOND) : '';
return `
<div class="episodeRow">
<img src="${item.imageUrl}" alt="" loading="lazy" onerror="this.classList.add('noImage')" />
<div class="episodeInfo">
<strong>${i + 1}. ${escapeHtml(item.name)}${duration ? ` <span class="hint">(${escapeHtml(duration)})</span>` : ''}</strong>
<p class="hint">${escapeHtml(meta)}</p>
</div>
</div>`;
}).join('');
launchBtn.addEventListener('click', async () => {
launchBtn.disabled = true;
launchBtn.textContent = 'Démarrage...';
try {
const party = await createParty(playlistId);
if (party.mediaType === 'music') {
// Same "no dedicated page for music" rule as everywhere else
// (views/album, views/player's own dispatcher) - the persistent
// bottom bar attaches and this page just goes back to being a
// normal page underneath it.
joinMusicParty(party.partyId, party);
navigate('/home');
} else {
navigate(`/party/${party.partyId}`);
}
} catch (err) {
alert((err as Error).message);
launchBtn.disabled = false;
launchBtn.textContent = 'Lancer cette playlist';
}
});
}