diff --git a/webui/static/discover.js b/webui/static/discover.js
index 3d9b6b15..4b1ec804 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -2388,13 +2388,11 @@ async function loadDecadeBrowserTabs() {
grid.className = 'discover-grid';
const mixes = data.decades.map(d => {
const year = d.year;
- const icon = getDecadeIcon(year);
return {
key: `decade_${year}`,
title: `${year}s`,
subtitle: `${year}s Classics`,
trackCount: d.track_count,
- coverHtml: `
${icon}${year}s
`,
statusBase: `decade-${year}`,
actions: [
{ label: 'Download', closeFirst: true, onclick: `openDownloadModalForDecade(${year})` },
@@ -3306,7 +3304,6 @@ function groupListenBrainzPlaylists(playlists) {
function buildListenBrainzPlaylistsHtml(playlists, tabId) {
// #discover redesign: each playlist is a mix card (opens its tracks + actions in the shared
// modal) instead of a full-width track-table subsection. Shared by Last.fm Radio + ListenBrainz.
- const icon = tabId === 'lastfm_radio' ? '\U0001F4FB' : '\U0001F3A7';
const mixes = playlists.map(playlist => {
const playlistData = playlist.playlist || playlist;
const identifier = playlistData.identifier?.split('/').pop() || '';
@@ -3339,7 +3336,6 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) {
return {
key: `lb-${tabId}-${identifier}`,
title, subtitle: `by ${creator}`, trackCount,
- coverHtml: `${icon}
`,
statusBase: playlistId,
statusHtml,
actions: [
@@ -3355,6 +3351,9 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) {
};
});
mixes.forEach(m => { _discoverMixRegistry[m.key] = m; });
+ // Caller injects this HTML synchronously; hydrate covers + tracks on the next tick so the cards
+ // exist in the DOM. Background-fetches each playlist's tracks → real mosaic + instant modal.
+ setTimeout(() => _hydrateMixCovers(mixes), 0);
return `${mixes.map(_buildMixCard).join('')}
`;
}
@@ -4395,26 +4394,42 @@ async function loadPersonalizedDailyMixes() {
}
}
+// Tracks come in two shapes across the discovery sources: flat (track_name/artist_name/…) for the
+// personalized mixes, or nested under track_data_json (name/artists[]/album/…) for the decade,
+// ListenBrainz + Last.fm playlists. Normalize both so renderers don't print "undefined". #discover
+function _normalizeTrack(track) {
+ const td = (track && track.track_data_json) || {};
+ const a0 = td.artists && td.artists[0];
+ return {
+ name: td.name || td.track_name || track.track_name || 'Unknown Track',
+ artist: (a0 && (a0.name || a0)) || td.artist_name || track.artist_name || 'Unknown Artist',
+ album: (td.album && td.album.name) || td.album_name || track.album_name || 'Unknown Album',
+ cover: (td.album && td.album.images && td.album.images[0] && td.album.images[0].url) || track.album_cover_url || '',
+ durationMs: td.duration_ms || track.duration_ms || 0,
+ };
+}
+
function renderCompactPlaylist(container, tracks) {
let html = '';
tracks.forEach((track, index) => {
- const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
- const durationMin = Math.floor(track.duration_ms / 60000);
- const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
+ const t = _normalizeTrack(track);
+ const coverUrl = t.cover || '/static/placeholder-album.png';
+ const durationMin = Math.floor(t.durationMs / 60000);
+ const durationSec = Math.floor((t.durationMs % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
${index + 1}
-

+
-
${track.track_name}
-
${track.artist_name}
+
${_esc(t.name)}
+
${_esc(t.artist)}
-
${track.album_name}
+
${_esc(t.album)}
${duration}
`;
@@ -4442,7 +4457,7 @@ function _buildMixCard(mix) {
} else {
const covers = [];
for (const t of (mix.tracks || [])) {
- const c = t.album_cover_url;
+ const c = _normalizeTrack(t).cover;
if (c && !covers.includes(c)) covers.push(c);
if (covers.length >= 4) break;
}
@@ -4452,7 +4467,7 @@ function _buildMixCard(mix) {
}
const count = mix.tracks ? mix.tracks.length : (mix.trackCount || 0);
return `
-
+
${cover}
${_esc(mix.title)}
${count} tracks
@@ -4463,11 +4478,42 @@ function _buildMixCard(mix) {
// Render a set of mix cards into ANY section grid (Time Machine, Last.fm Radio, ListenBrainz…) and
// register them so openMixModalByKey can find them. Unlike _upsertMixCard this targets a given
// container rather than the shared "Your Mixes" shelf, so each section keeps its own header.
+// For sections whose tracks load lazily (decades, Last.fm, ListenBrainz), fetch each mix's tracks in
+// the background and upgrade its card from the placeholder to a real 2x2 mosaic — and stash the
+// tracks on the mix so opening the modal is instant. #discover redesign
+function _hydrateMixCovers(mixes) {
+ mixes.forEach(mix => {
+ if (mix.tracks || !mix.fetchTracks) return;
+ Promise.resolve(mix.fetchTracks()).then(tracks => {
+ mix.tracks = tracks || [];
+ const card = document.querySelector(`.discover-mix-card[data-mix-key="${mix.key}"]`);
+ if (!card) return;
+ const meta = card.querySelector('.mix-card-meta');
+ if (meta) meta.textContent = `${mix.tracks.length} tracks`;
+ const covers = [];
+ for (const t of mix.tracks) {
+ const c = _normalizeTrack(t).cover;
+ if (c && !covers.includes(c)) covers.push(c);
+ if (covers.length >= 4) break;
+ }
+ if (!covers.length) return; // no art — leave the placeholder cover
+ while (covers.length < 4) covers.push('/static/placeholder-album.png');
+ const coverEl = card.querySelector('.mix-card-cover');
+ if (coverEl) {
+ coverEl.className = 'mix-card-cover';
+ coverEl.innerHTML = covers.map(c => `
`).join('')
+ + '
▶
';
+ }
+ }).catch(() => {});
+ });
+}
+
function _renderMixGrid(container, mixes) {
if (!container) return;
mixes.forEach(m => { _discoverMixRegistry[m.key] = m; });
container.innerHTML = mixes.map(_buildMixCard).join('');
_clampGrid(container);
+ _hydrateMixCovers(mixes);
}
// Register/refresh a mix's card in the "Your Mixes" shelf and reveal the section.