watchparty/client/src/views/playlist/Playlist.ts
Nicolas ff664f2c2b
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Replace every blocking alert() with a light toast, fix a mobile grid bug
Every message this app ever showed the client - the host-handoff
notice, every "couldn't start/join a party" error - went through a
blocking alert(), freezing the tab for what's usually just a heads-up.
New toast.ts is a self-mounting singleton (no per-party state, so it
doesn't need Shell.ts wiring like musicBar/MusicBar does) styled after
Player.css's own .requestBanner, with an 'error' variant that's still
just a plain left accent, not a red banner - even a failed click
doesn't need this app to suddenly shout. All ten alert() call sites
across Player/MusicBar/tiles/Home/Series/Playlist/Artist/Album now go
through it.

Also fixes a real reported bug while in the area: the library/artist
grid's minmax(160px, 1fr) floor left no room for a second column once
a real phone's padding was subtracted, unlike everything else mobile
in this app (the sidebar, the music bar) which already got a
narrow-screen treatment. Switched to a fluid clamp(100px, 28vw, 160px)
floor instead of a fixed breakpoint, confirmed at seven real widths via
an iframe (its own independent viewport for vw units, sidestepping
this sandbox's own unreliable viewport-resize emulation).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 17:34:10 +02:00

108 lines
4.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 { showToast } from '../../toast';
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. Two actions:
* "Lancer cette playlist" (always from the first item) and, per row,
* "Lire à partir d'ici" - both launch a party pre-queued server-side
* (see index.ts's createPartyFromPlaylist), the second just also
* passing which item to start from. That second one could only ever
* be built here, on this exact page: unlike an Episode's own SeriesId,
* a playlist item carries no back-reference to "which playlist
* contains me", so resolving "item N of playlist P" needs playlist
* P's own already-fetched item list in hand, not just the clicked
* item's bare id - views/library's tiles and views/album's per-track
* rows have no such list to offer, only this page does.
*/
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>
<button class="startFromBtn" data-item-id="${item.id}">Lire à partir d'ici</button>
</div>`;
}).join('');
/**
* Shared by "Lancer cette playlist" (startAtItemId omitted - the
* server then just starts from the playlist's own first item, same
* as always) and every row's own "Lire à partir d'ici" - only the
* button clicked, its busy label, and whether a starting point is
* passed differ; everything after that (create, dispatch on
* mediaType, error handling) is identical either way.
*/
async function launch(btn: HTMLButtonElement, busyLabel: string, idleLabel: string, startAtItemId?: string) {
btn.disabled = true;
btn.textContent = busyLabel;
try {
const party = await createParty(playlistId, startAtItemId);
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) {
showToast((err as Error).message, { variant: 'error' });
btn.disabled = false;
btn.textContent = idleLabel;
}
}
launchBtn.addEventListener('click', () => launch(launchBtn, 'Démarrage...', 'Lancer cette playlist'));
listEl.querySelectorAll<HTMLButtonElement>('.startFromBtn').forEach((btn) => {
btn.addEventListener('click', () => launch(btn, 'Démarrage...', "Lire à partir d'ici", btn.dataset.itemId));
});
}