discover: generalize the mix modal + convert Time Machine to "Year Mixes" cards
Foundation for folding the remaining playlist sections into the mix-card system:
- openMixModal now takes a custom `actions` list (not just the built-in Download/Sync), a `statusBase`
so any sections live sync lands in the modal, a `pollerKey` for in-flight re-show, and `fetchTracks`
for lazy sections that load tracks on open.
- _buildMixCard supports a non-mosaic `coverHtml` + `trackCount` (for art-less cards like decades).
- new _renderMixGrid(container, mixes) renders cards into any sections own grid (not the shared shelf).
Time Machine -> "Year Mixes": a shelf of decade cards (gradient cover + "1980s" label); opening one
lazy-loads /api/discover/decade/{year} into the modal (populating decadeTracksCache so Sync works)
with Download/Sync. Tabs removed.
Fix: _upsertMixCard rendered the WHOLE shared registry into Your Mixes, so decades (added by
_renderMixGrid for the modal lookup) leaked in. Your Mixes now tracks its own _yourMixKeys and renders
only those. 64 script-integrity tests green.
This commit is contained in:
parent
880ad74a0a
commit
153fad152c
3 changed files with 128 additions and 34 deletions
|
|
@ -3781,21 +3781,18 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Time Machine (Tabbed by Decade) -->
|
<!-- Year Mixes (decade mix cards — same setup as Your Mixes) -->
|
||||||
<div class="discover-section">
|
<div class="discover-section" id="year-mixes-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<h2 class="discover-section-title">⏰ Time Machine</h2>
|
<div>
|
||||||
<p class="discover-section-subtitle">Explore music from different decades</p>
|
<h2 class="discover-section-title">Year Mixes</h2>
|
||||||
</div>
|
<p class="discover-section-subtitle">Jump into the sound of a decade — open one to see the tracks</p>
|
||||||
|
|
||||||
<!-- Decade Tabs (will be populated dynamically) -->
|
|
||||||
<div class="decade-tabs" id="decade-tabs">
|
|
||||||
<div class="discover-loading">
|
|
||||||
<div class="loading-spinner"></div>
|
|
||||||
<p>Loading decades...</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Decade Tabs (hidden — replaced by the decade mix-card grid below) -->
|
||||||
|
<div class="decade-tabs" id="decade-tabs" style="display:none"></div>
|
||||||
|
|
||||||
<!-- Decade Tab Contents (will be populated dynamically) -->
|
<!-- Decade Tab Contents (will be populated dynamically) -->
|
||||||
<div id="decade-tab-contents"></div>
|
<div id="decade-tab-contents"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2371,7 +2371,46 @@ function _renderTabbedTrackList(tracks) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDecadeBrowserTabs() {
|
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: `<div class="mix-card-decade"><span class="mix-card-decade-icon">${icon}</span><span class="mix-card-decade-label">${year}s</span></div>`,
|
||||||
|
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) {
|
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
|
// 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).
|
// into these cards (the table now lives inside the opened mix, where it belongs).
|
||||||
const _discoverMixRegistry = {};
|
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) {
|
function _buildMixCard(mix) {
|
||||||
const covers = [];
|
let cover;
|
||||||
for (const t of mix.tracks) {
|
if (mix.coverHtml) {
|
||||||
const c = t.album_cover_url;
|
// Sections with no per-track art (e.g. decades) supply their own cover (a gradient + label).
|
||||||
if (c && !covers.includes(c)) covers.push(c);
|
cover = `<div class="mix-card-cover mix-card-cover--solid">${mix.coverHtml}<div class="mix-card-play">▶</div></div>`;
|
||||||
if (covers.length >= 4) break;
|
} 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 => `<div class="mix-card-tile" style="background-image:url('${c}')"></div>`).join('');
|
||||||
|
cover = `<div class="mix-card-cover">${mosaic}<div class="mix-card-play">▶</div></div>`;
|
||||||
}
|
}
|
||||||
while (covers.length < 4) covers.push('/static/placeholder-album.png');
|
const count = mix.tracks ? mix.tracks.length : (mix.trackCount || 0);
|
||||||
const mosaic = covers.map(c => `<div class="mix-card-tile" style="background-image:url('${c}')"></div>`).join('');
|
|
||||||
return `
|
return `
|
||||||
<div class="discover-mix-card" onclick="openMixModalByKey('${mix.key}')">
|
<div class="discover-mix-card" onclick="openMixModalByKey('${mix.key}')">
|
||||||
<div class="mix-card-cover">
|
${cover}
|
||||||
${mosaic}
|
|
||||||
<div class="mix-card-play">▶</div>
|
|
||||||
</div>
|
|
||||||
<div class="mix-card-name">${_esc(mix.title)}</div>
|
<div class="mix-card-name">${_esc(mix.title)}</div>
|
||||||
<div class="mix-card-meta">${mix.tracks.length} tracks</div>
|
<div class="mix-card-meta">${count} tracks</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
// Register/refresh a mix's card in the "Your Mixes" shelf and reveal the section.
|
||||||
function _upsertMixCard(mix) {
|
function _upsertMixCard(mix) {
|
||||||
_discoverMixRegistry[mix.key] = mix;
|
_discoverMixRegistry[mix.key] = mix;
|
||||||
|
if (!_yourMixKeys.includes(mix.key)) _yourMixKeys.push(mix.key);
|
||||||
const shelf = document.getElementById('your-mixes-grid');
|
const shelf = document.getElementById('your-mixes-grid');
|
||||||
if (!shelf) return;
|
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');
|
const section = document.getElementById('your-mixes-section');
|
||||||
if (section) section.style.display = 'block';
|
if (section) section.style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
@ -4472,12 +4531,26 @@ function openMixModal(mix) {
|
||||||
// statusBase mirrors startDiscoverPlaylistSync's id convention (underscores → hyphens) so
|
// 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
|
// 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).
|
// section's duplicates are stripped in _collapseOldMixSection so there's no id clash).
|
||||||
const base = mix.syncKey ? mix.syncKey.replace(/_/g, '-') : '';
|
// A mix either uses the built-in syncKey path (Download + Sync via startDiscoverPlaylistSync)
|
||||||
// Download opens its own modal beneath this one — close this first so it's interactable.
|
// or supplies a custom `actions` list + `statusBase` (decades, ListenBrainz, Last.fm radio) so
|
||||||
const actions = mix.syncKey ? `
|
// any playlist section can reuse this modal. statusBase drives the live sync-status element ids.
|
||||||
<button class="btn btn--sm btn--secondary" onclick="document.getElementById('mix-modal-overlay').remove(); openDownloadModalForDiscoverPlaylist('${mix.syncKey}', '${escapeForInlineJs(mix.title)}')">Download</button>
|
const base = mix.statusBase || (mix.syncKey ? mix.syncKey.replace(/_/g, '-') : '');
|
||||||
<button class="btn btn--sm btn--primary" id="${base}-sync-btn" onclick="startDiscoverPlaylistSync('${mix.syncKey}', '${escapeForInlineJs(mix.title)}')">Sync</button>` : '';
|
const closeFirst = "document.getElementById('mix-modal-overlay').remove(); ";
|
||||||
const syncStatus = mix.syncKey ? `
|
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 `<button class="${cls}"${idAttr} onclick="${oc}">${_esc(a.label)}</button>`;
|
||||||
|
}).join('');
|
||||||
|
const syncStatus = base ? `
|
||||||
<div class="discover-sync-status" id="${base}-sync-status" style="display:none">
|
<div class="discover-sync-status" id="${base}-sync-status" style="display:none">
|
||||||
<div class="sync-status-content">
|
<div class="sync-status-content">
|
||||||
<div class="sync-status-label"><span class="sync-icon">⟳</span><span>Syncing to media server...</span></div>
|
<div class="sync-status-label"><span class="sync-icon">⟳</span><span>Syncing to media server...</span></div>
|
||||||
|
|
@ -4495,7 +4568,7 @@ function openMixModal(mix) {
|
||||||
<div>
|
<div>
|
||||||
<div class="mix-modal-subtitle">${_esc(mix.subtitle || 'Mix')}</div>
|
<div class="mix-modal-subtitle">${_esc(mix.subtitle || 'Mix')}</div>
|
||||||
<h2 class="mix-modal-title">${_esc(mix.title)}</h2>
|
<h2 class="mix-modal-title">${_esc(mix.title)}</h2>
|
||||||
<div class="mix-modal-meta">${mix.tracks.length} tracks</div>
|
<div class="mix-modal-meta">${mix.tracks ? mix.tracks.length + ' tracks' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mix-modal-actions">
|
<div class="mix-modal-actions">
|
||||||
${actions}
|
${actions}
|
||||||
|
|
@ -4507,12 +4580,25 @@ function openMixModal(mix) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(overlay);
|
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 = '<div class="discover-empty"><p>Loading tracks…</p></div>';
|
||||||
|
Promise.resolve(mix.fetchTracks()).then(tracks => {
|
||||||
|
mix.tracks = tracks || [];
|
||||||
|
const metaEl = overlay.querySelector('.mix-modal-meta');
|
||||||
|
if (metaEl) metaEl.textContent = `${mix.tracks.length} tracks`;
|
||||||
|
renderCompactPlaylist(body, mix.tracks);
|
||||||
|
}).catch(() => { body.innerHTML = '<div class="discover-empty"><p>Failed to load tracks</p></div>'; });
|
||||||
|
}
|
||||||
|
|
||||||
// If a sync for this mix is already in flight (the modal was closed mid-sync), reveal the
|
// If a sync for this mix is already in flight (the modal was closed mid-sync), reveal the
|
||||||
// live status + disable the button so re-opening picks the progress back up — the next
|
// live status + disable the button so re-opening picks the progress back up — the next
|
||||||
// poll/WebSocket tick refills the counters onto this fresh modal's elements.
|
// poll/WebSocket tick refills the counters onto this fresh modal's elements.
|
||||||
if (mix.syncKey && discoverSyncPollers[mix.syncKey]) {
|
const pollerKey = mix.pollerKey || mix.syncKey;
|
||||||
|
if (pollerKey && discoverSyncPollers[pollerKey]) {
|
||||||
const statusEl = document.getElementById(`${base}-sync-status`);
|
const statusEl = document.getElementById(`${base}-sync-status`);
|
||||||
if (statusEl) statusEl.style.display = 'block';
|
if (statusEl) statusEl.style.display = 'block';
|
||||||
const btnEl = document.getElementById(`${base}-sync-btn`);
|
const btnEl = document.getElementById(`${base}-sync-btn`);
|
||||||
|
|
|
||||||
|
|
@ -35071,6 +35071,17 @@ div.artist-hero-badge {
|
||||||
font-size: 12px; color: rgba(255,255,255,0.5);
|
font-size: 12px; color: rgba(255,255,255,0.5);
|
||||||
margin-top: 3px;
|
margin-top: 3px;
|
||||||
}
|
}
|
||||||
|
/* Solid/gradient cover for mixes with no per-track art (e.g. decades). */
|
||||||
|
.mix-card-cover--solid {
|
||||||
|
background: linear-gradient(135deg, rgba(var(--accent-rgb),0.42), rgba(var(--accent-rgb),0.10));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.mix-card-decade {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 4px; color: #fff; text-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
.mix-card-decade-icon { font-size: 34px; line-height: 1; }
|
||||||
|
.mix-card-decade-label { font-size: 22px; font-weight: 800; letter-spacing: 0.5px; }
|
||||||
/* The mix modal — opened from a mix card; holds the track list + per-mix actions. */
|
/* The mix modal — opened from a mix card; holds the track list + per-mix actions. */
|
||||||
.mix-modal {
|
.mix-modal {
|
||||||
background: #14141c; border-radius: 18px;
|
background: #14141c; border-radius: 18px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue