import './Album.css'; import '../../styles/trackList.css'; import html from './Album.html'; import { getAlbumTracks, getItem } from '../../api/embyApi'; import { addTrackToQueue, createParty, queueItem } from '../../api/party'; import { getActiveMusicPartyId, joinMusicParty } from '../../musicBar/MusicBar'; import { escapeHtml, formatDuration, wireSearchFilter } from '../../tiles'; import { LOADING_HINT } from '../../loading'; import { showToast } from '../../toast'; import type { ItemDto, MusicTrackInput } from '../../types'; import type { RouteParams } from '../../router'; const TICKS_PER_SECOND = 10000000; // Emby/.NET convention: 1 tick = 100ns, same as everywhere else this app touches durations/positions // Same "small local constant" convention as musicBar/MusicBar.ts's own // icon strings - PLAY_ICON_SVG is duplicated from views/artist/Artist.ts // rather than shared, see that file's own comment on why. const PLAY_ICON_SVG = ''; const PLUS_ICON_SVG = ''; const CHECK_ICON_SVG = ''; /** Route `/album/:id` - the last level of the music hierarchy: one album's * tracks, each startable as its own listen party (mirrors views/series's * episode list, but flat - an album has no further sub-level the way a * series' seasons do). */ export async function renderAlbum(root: HTMLElement, params: RouteParams): Promise { const albumId = params.id as string; root.innerHTML = LOADING_HINT; let album: ItemDto; let tracks: ItemDto[]; try { [album, tracks] = await Promise.all([getItem(albumId), getAlbumTracks(albumId)]); } catch { root.innerHTML = '

Impossible de charger cet album.

'; return; } root.innerHTML = html; root.querySelector('#back')!.addEventListener('click', () => history.back()); (root.querySelector('#albumCover') as HTMLImageElement).src = album.imageUrl; root.querySelector('#albumKicker')!.textContent = album.year ? `Album · ${album.year}` : 'Album'; root.querySelector('#albumTitle')!.textContent = album.name; root.querySelector('#launchBtnIcon')!.innerHTML = PLAY_ICON_SVG; if (album.artistName) { const artistEl = root.querySelector('#albumArtist')!; artistEl.textContent = album.artistName; artistEl.classList.remove('hide'); } (root.querySelector('#embyLink') as HTMLAnchorElement).href = album.embyUrl; const listEl = root.querySelector('#trackList')!; const launchBtn = root.querySelector('#launchBtn') as HTMLButtonElement; const launchBtnLabel = root.querySelector('#launchBtnLabel')!; if (!tracks.length) { listEl.innerHTML = '

Aucun morceau dans cet album.

'; root.querySelector('#searchInput')!.remove(); launchBtn.disabled = true; return; } // aria-label reflects whether a listen party is already active *at // render time* - a harmless, occasionally-stale label (see // refreshLabels() below for when it's corrected). The actual behavior // on click always re-checks getActiveMusicPartyId() fresh, so clicking // never does the wrong thing even if the label hasn't caught up yet // (e.g. someone hit "Quitter" on the bar while this page was already // open). Icon-only buttons (play vs plus, mirroring the same two // states) - the label lives in aria-label/title for accessibility // rather than as visible text, now that there's one per track instead // of one per page (see docs/architecture.md's "Music UI rework"). const startLabel = 'Écouter ensemble'; const addLabel = 'Ajouter à la file'; const startAlbumLabel = 'Écouter cet album'; const addAlbumLabel = "Ajouter l'album à la file"; listEl.innerHTML = tracks.map((t) => `
${t.indexNumber != null ? t.indexNumber : ''} ${escapeHtml(t.name)} ${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}
`).join(''); function trackInputFor(t: ItemDto): MusicTrackInput { return { itemId: t.id, itemName: t.name, artistName: t.artistName, albumName: t.albumName, itemImageUrl: t.imageUrl, durationTicks: t.durationTicks }; } // Re-labels every track button (and the whole-album one) from current // state, not just the one just clicked - starting a party from track 3 // means tracks 1, 2 and 4 can now also be queued instead of starting a // party of their own. function refreshLabels(): void { const active = getActiveMusicPartyId(); listEl.querySelectorAll('.startPartyBtn').forEach((b) => { if (b.disabled) return; const label = active ? addLabel : startLabel; b.innerHTML = active ? PLUS_ICON_SVG : PLAY_ICON_SVG; b.title = label; b.setAttribute('aria-label', label); }); if (!launchBtn.disabled) launchBtnLabel.textContent = active ? addAlbumLabel : startAlbumLabel; } refreshLabels(); // sets launchBtn's own initial label - the per-track buttons already get theirs from the template above listEl.querySelectorAll('.startPartyBtn').forEach((btn, i) => { const track = tracks[i] as ItemDto; btn.addEventListener('click', async () => { btn.disabled = true; const activePartyId = getActiveMusicPartyId(); try { if (activePartyId) { await addTrackToQueue(activePartyId, trackInputFor(track)); } else { // Attaches the persistent bottom bar directly and stays right // here - no navigate() - so adding a few more tracks from this // same album is just clicking their buttons too. See // Player.ts's dispatcher for the equivalent when a party is // instead joined from elsewhere (home's live list, etc). const party = await createParty(track.id); joinMusicParty(party.partyId, party); } btn.innerHTML = CHECK_ICON_SVG; setTimeout(() => { btn.disabled = false; refreshLabels(); }, 1500); } catch (err) { showToast((err as Error).message, { variant: 'error' }); btn.disabled = false; refreshLabels(); } }); }); // Whole-album equivalent of the per-track buttons above - asked for // directly, alongside the same action for a whole artist (see // Artist.ts). itemId is the album itself, not any one track - the // server expands it into every track inside, in order (see index.ts's // resolveMusicTracksForItem/createMusicPartyFromTracks), the same way // launching a playlist already expands into everything it contains. launchBtn.addEventListener('click', async () => { launchBtn.disabled = true; const activePartyId = getActiveMusicPartyId(); try { if (activePartyId) { launchBtnLabel.textContent = 'Ajout...'; await queueItem(activePartyId, albumId); } else { launchBtnLabel.textContent = 'Démarrage...'; const party = await createParty(albumId); joinMusicParty(party.partyId, party); } launchBtnLabel.textContent = 'Ajouté !'; setTimeout(() => { launchBtn.disabled = false; refreshLabels(); }, 1500); } catch (err) { showToast((err as Error).message, { variant: 'error' }); launchBtn.disabled = false; refreshLabels(); } }); wireSearchFilter(root.querySelector('#searchInput') as HTMLInputElement, root, '.trackRow'); }