watchparty/client/src/views/album/Album.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

169 lines
8.1 KiB
TypeScript

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 = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4.5v15l14-7.5Z" fill="currentColor"></path></svg>';
const PLUS_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line><line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></line></svg>';
const CHECK_ICON_SVG = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12.5 9 17.5 20 6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"></path></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<void> {
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 = '<p class="error">Impossible de charger cet album.</p>';
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 = '<p class="hint">Aucun morceau dans cet album.</p>';
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) => `
<div class="trackRow">
<span class="trackIndex">${t.indexNumber != null ? t.indexNumber : ''}</span>
<span class="trackName">${escapeHtml(t.name)}</span>
<span class="trackDuration">${t.durationTicks ? formatDuration(t.durationTicks / TICKS_PER_SECOND) : ''}</span>
<button class="startPartyBtn trackIconBtn" data-item-id="${t.id}" title="${getActiveMusicPartyId() ? addLabel : startLabel}" aria-label="${getActiveMusicPartyId() ? addLabel : startLabel}">${getActiveMusicPartyId() ? PLUS_ICON_SVG : PLAY_ICON_SVG}</button>
</div>
`).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<HTMLButtonElement>('.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<HTMLButtonElement>('.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');
}