Jump into the sound of a decade — open one to see the tracks
+
+
+
diff --git a/webui/static/discover.js b/webui/static/discover.js
index 6792ad34..86b64721 100644
--- a/webui/static/discover.js
+++ b/webui/static/discover.js
@@ -2371,7 +2371,46 @@ function _renderTabbedTrackList(tracks) {
}
async function loadDecadeBrowserTabs() {
- return _getDecadeBrowserTabsCtrl().loadTabs();
+ // #discover redesign: Time Machine is now a shelf of decade mix cards (each opens its tracks in
+ // the shared modal) instead of a tabbed track table. Tracks load lazily on open.
+ const grid = document.getElementById('decade-tab-contents');
+ if (!grid) return;
+ const tabs = document.getElementById('decade-tabs');
+ if (tabs) tabs.style.display = 'none';
+ try {
+ const resp = await fetch('/api/discover/decades/available');
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (!data.success || !Array.isArray(data.decades) || !data.decades.length) {
+ grid.closest('.discover-section')?.style.setProperty('display', 'none');
+ return;
+ }
+ 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})` },
+ { label: 'Sync', primary: true, isSync: true, onclick: `startDecadeSync(${year})` },
+ ],
+ // Populate decadeTracksCache like the old tab fetch did so startDecadeSync works.
+ fetchTracks: () => fetch(`/api/discover/decade/${year}`).then(r => r.json()).then(dd => {
+ const tracks = (dd && dd.tracks) || [];
+ decadeTracksCache[year] = tracks;
+ activeDecade = year;
+ return tracks;
+ }),
+ };
+ });
+ _renderMixGrid(grid, mixes);
+ } catch (e) { console.debug('Decade browser:', e); }
}
function switchDecadeTab(decade) {
@@ -4416,34 +4455,54 @@ function renderCompactPlaylist(container, tracks) {
// the track list + actions in a modal. The old per-mix full-width tables collapse
// into these cards (the table now lives inside the opened mix, where it belongs).
const _discoverMixRegistry = {};
+// Keys that belong to the shared "Your Mixes" shelf specifically. The registry above also holds
+// other sections' mixes (decades, Last.fm, ListenBrainz) for openMixModalByKey, so the shelf must
+// render only its own keys — not Object.values(registry), or those sections leak in.
+const _yourMixKeys = [];
function _buildMixCard(mix) {
- const covers = [];
- for (const t of mix.tracks) {
- const c = t.album_cover_url;
- if (c && !covers.includes(c)) covers.push(c);
- if (covers.length >= 4) break;
+ let cover;
+ if (mix.coverHtml) {
+ // Sections with no per-track art (e.g. decades) supply their own cover (a gradient + label).
+ cover = `
${mix.coverHtml}
▶
`;
+ } else {
+ const covers = [];
+ for (const t of (mix.tracks || [])) {
+ const c = t.album_cover_url;
+ if (c && !covers.includes(c)) covers.push(c);
+ if (covers.length >= 4) break;
+ }
+ while (covers.length < 4) covers.push('/static/placeholder-album.png');
+ const mosaic = covers.map(c => ``).join('');
+ cover = `
`;
}
+// 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.
+function _renderMixGrid(container, mixes) {
+ if (!container) return;
+ mixes.forEach(m => { _discoverMixRegistry[m.key] = m; });
+ container.innerHTML = mixes.map(_buildMixCard).join('');
+ _clampGrid(container);
+}
+
// Register/refresh a mix's card in the "Your Mixes" shelf and reveal the section.
function _upsertMixCard(mix) {
_discoverMixRegistry[mix.key] = mix;
+ if (!_yourMixKeys.includes(mix.key)) _yourMixKeys.push(mix.key);
const shelf = document.getElementById('your-mixes-grid');
if (!shelf) return;
- shelf.innerHTML = Object.values(_discoverMixRegistry).map(_buildMixCard).join('');
+ shelf.innerHTML = _yourMixKeys.map(k => _buildMixCard(_discoverMixRegistry[k])).join('');
const section = document.getElementById('your-mixes-section');
if (section) section.style.display = 'block';
}
@@ -4472,12 +4531,26 @@ function openMixModal(mix) {
// statusBase mirrors startDiscoverPlaylistSync's id convention (underscores → hyphens) so
// the sync's live-progress updates land on THIS modal's status elements (the old hidden
// section's duplicates are stripped in _collapseOldMixSection so there's no id clash).
- const base = mix.syncKey ? mix.syncKey.replace(/_/g, '-') : '';
- // Download opens its own modal beneath this one — close this first so it's interactable.
- const actions = mix.syncKey ? `
-
- ` : '';
- const syncStatus = mix.syncKey ? `
+ // A mix either uses the built-in syncKey path (Download + Sync via startDiscoverPlaylistSync)
+ // or supplies a custom `actions` list + `statusBase` (decades, ListenBrainz, Last.fm radio) so
+ // any playlist section can reuse this modal. statusBase drives the live sync-status element ids.
+ const base = mix.statusBase || (mix.syncKey ? mix.syncKey.replace(/_/g, '-') : '');
+ const closeFirst = "document.getElementById('mix-modal-overlay').remove(); ";
+ let actionList = mix.actions;
+ if (!actionList && mix.syncKey) {
+ // Download opens its own modal beneath this one — close this first so it's interactable.
+ actionList = [
+ { label: 'Download', closeFirst: true, onclick: `openDownloadModalForDiscoverPlaylist('${mix.syncKey}', '${escapeForInlineJs(mix.title)}')` },
+ { label: 'Sync', primary: true, isSync: true, onclick: `startDiscoverPlaylistSync('${mix.syncKey}', '${escapeForInlineJs(mix.title)}')` },
+ ];
+ }
+ const actions = (actionList || []).map(a => {
+ const cls = a.primary ? 'btn btn--sm btn--primary' : 'btn btn--sm btn--secondary';
+ const idAttr = (a.isSync && base) ? ` id="${base}-sync-btn"` : '';
+ const oc = (a.closeFirst ? closeFirst : '') + a.onclick;
+ return ``;
+ }).join('');
+ const syncStatus = base ? `
⟳Syncing to media server...
@@ -4495,7 +4568,7 @@ function openMixModal(mix) {
${actions}
@@ -4507,12 +4580,25 @@ function openMixModal(mix) {
`;
document.body.appendChild(overlay);
- renderCompactPlaylist(document.getElementById('mix-modal-tracks'), mix.tracks);
+ const body = document.getElementById('mix-modal-tracks');
+ if (mix.tracks) {
+ renderCompactPlaylist(body, mix.tracks);
+ } else if (mix.fetchTracks) {
+ // Lazy sections (e.g. decades) load their tracks on open; fetch then fill in the count.
+ body.innerHTML = '