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>
169 lines
8.1 KiB
TypeScript
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');
|
|
}
|