diff --git a/webui/index.html b/webui/index.html index 6d72c777..58db7919 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3781,21 +3781,18 @@ - -
+ +
-

⏰ Time Machine

-

Explore music from different decades

-
- - -
-
-
-

Loading decades...

+
+

Year Mixes

+

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 = `
${mosaic}
`; } - while (covers.length < 4) covers.push('/static/placeholder-album.png'); - const mosaic = covers.map(c => `
`).join(''); + const count = mix.tracks ? mix.tracks.length : (mix.trackCount || 0); return `
-
- ${mosaic} -
-
+ ${cover}
${_esc(mix.title)}
-
${mix.tracks.length} tracks
+
${count} tracks
`; } +// 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 ? `