discover: "Your Mixes" shelf — mixes are cards that open to a track list (Spotify-style)

Reworks the Discover page mixes from full-width compact-track tables into a consistent shelf of
playlist cards, the way Spotify Home does it. Each mix is ONE card (a 2x2 mosaic cover from its top
tracks + name/count, hover play button); clicking it opens a modal with the track list + the mix
actions — the track table now lives where a track list belongs (inside the opened mix).

- new "Your Mixes" section + .discover-mix-card / .discover-mixes-grid + .mix-modal styles.
- _buildMixCard / _upsertMixCard / openMixModal + a mix registry; the four personalized track-mix
  loaders (Listening Mix, Popular Picks, Hidden Gems, Discovery Shuffle) collapse their old table
  section into a card via _collapseOldMixSection (which also strips the old section's duplicate
  sync ids so the modal owns the live status).
- modal actions use the standard btn--secondary/btn--primary; Download closes the mix modal first
  so its own modal is interactable; Sync shows live progress IN the modal and survives close/re-open
  (detects an in-flight sync via discoverSyncPollers and re-reveals the status).

64 script-integrity tests green. Next: fold the remaining mixes (Fresh Tape / The Archives /
Seasonal / Daily Mixes) into the same shelf, then unify the album/genre sections onto the card.
This commit is contained in:
BoulderBadgeDad 2026-06-29 13:40:34 -07:00
parent 4b8ddad9ff
commit 9d5e58c4b8
3 changed files with 236 additions and 8 deletions

View file

@ -3161,6 +3161,18 @@
</div>
<!-- #913: Listening Mix — a playable track mix from those recommended artists -->
<!-- Your Mixes — Spotify-style shelf: each mix is ONE card (mosaic cover),
click opens the track list + actions in a modal (#discover redesign). -->
<div class="discover-section" id="your-mixes-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Your Mixes</h2>
<p class="discover-section-subtitle">Fresh playlists built from your listening — open one to see the tracks</p>
</div>
</div>
<div class="discover-mixes-grid" id="your-mixes-grid"></div>
</div>
<div class="discover-section" style="display: none;">
<div class="discover-section-header">
<div>

View file

@ -4278,8 +4278,12 @@ async function loadPersonalizedPopularPicks() {
}
personalizedPopularPicks = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
_upsertMixCard({
key: 'popular_picks', title: 'Popular Picks',
subtitle: 'Popular tracks from artists you love',
tracks: data.tracks, syncKey: 'popular_picks',
});
_collapseOldMixSection(container);
} catch (error) {
console.error('Error loading popular picks:', error);
@ -4301,8 +4305,12 @@ async function loadPersonalizedHiddenGems() {
}
personalizedHiddenGems = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
_upsertMixCard({
key: 'hidden_gems', title: 'Hidden Gems',
subtitle: 'Deeper cuts you might have missed',
tracks: data.tracks, syncKey: 'hidden_gems',
});
_collapseOldMixSection(container);
} catch (error) {
console.error('Error loading hidden gems:', error);
@ -4327,8 +4335,14 @@ async function loadPersonalizedListeningMix() {
}
personalizedListeningMix = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
// Spotify-style: collapse this mix into a card in the "Your Mixes" shelf; its
// track list + actions live in the modal you open from the card (#discover redesign).
_upsertMixCard({
key: 'listening_mix', title: 'Your Listening Mix',
subtitle: 'From artists matched to your listening',
tracks: data.tracks, syncKey: 'listening_mix',
});
_collapseOldMixSection(container);
} catch (error) {
console.error('Error loading listening mix:', error);
@ -4410,6 +4424,115 @@ function renderCompactPlaylist(container, tracks) {
container.innerHTML = html;
}
// ── Your Mixes shelf (#discover redesign) ───────────────────────────────────
// Each mix is ONE card (a 2x2 mosaic cover from its top tracks); clicking it opens
// 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 = {};
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;
}
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('');
return `
<div class="discover-mix-card" onclick="openMixModalByKey('${mix.key}')">
<div class="mix-card-cover">
${mosaic}
<div class="mix-card-play">&#9654;</div>
</div>
<div class="mix-card-name">${_esc(mix.title)}</div>
<div class="mix-card-meta">${mix.tracks.length} tracks</div>
</div>
`;
}
// Register/refresh a mix's card in the "Your Mixes" shelf and reveal the section.
function _upsertMixCard(mix) {
_discoverMixRegistry[mix.key] = mix;
const shelf = document.getElementById('your-mixes-grid');
if (!shelf) return;
shelf.innerHTML = Object.values(_discoverMixRegistry).map(_buildMixCard).join('');
const section = document.getElementById('your-mixes-section');
if (section) section.style.display = 'block';
}
// Collapse a now-redundant per-mix table section out of view, and strip its sync-status +
// sync-button so their ids can't shadow the modal's (the modal owns the live sync display
// now). Keeps the container so each loader's "already loaded?" guard still works on refresh.
function _collapseOldMixSection(container) {
const section = container.closest('.discover-section');
if (!section) return;
section.querySelectorAll('.discover-sync-status, [id$="-sync-btn"]').forEach(el => el.remove());
section.style.display = 'none';
}
function openMixModalByKey(key) {
const mix = _discoverMixRegistry[key];
if (mix) openMixModal(mix);
}
function openMixModal(mix) {
document.getElementById('mix-modal-overlay')?.remove();
const overlay = document.createElement('div');
overlay.id = 'mix-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
// 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 ? `
<button class="btn btn--sm btn--secondary" onclick="document.getElementById('mix-modal-overlay').remove(); openDownloadModalForDiscoverPlaylist('${mix.syncKey}', '${escapeForInlineJs(mix.title)}')">Download</button>
<button class="btn btn--sm btn--primary" id="${base}-sync-btn" onclick="startDiscoverPlaylistSync('${mix.syncKey}', '${escapeForInlineJs(mix.title)}')">Sync</button>` : '';
const syncStatus = mix.syncKey ? `
<div class="discover-sync-status" id="${base}-sync-status" style="display:none">
<div class="sync-status-content">
<div class="sync-status-label"><span class="sync-icon">&#10227;</span><span>Syncing to media server...</span></div>
<div class="sync-status-stats">
<span class="sync-stat">&#10003; <span id="${base}-sync-completed">0</span></span>
<span class="sync-stat">&#9203; <span id="${base}-sync-pending">0</span></span>
<span class="sync-stat">&#10007; <span id="${base}-sync-failed">0</span></span>
<span class="sync-stat">(<span id="${base}-sync-percentage">0</span>%)</span>
</div>
</div>
</div>` : '';
overlay.innerHTML = `
<div class="mix-modal">
<div class="mix-modal-header">
<div>
<div class="mix-modal-subtitle">${_esc(mix.subtitle || 'Mix')}</div>
<h2 class="mix-modal-title">${_esc(mix.title)}</h2>
<div class="mix-modal-meta">${mix.tracks.length} tracks</div>
</div>
<div class="mix-modal-actions">
${actions}
<button class="mix-modal-close" onclick="document.getElementById('mix-modal-overlay').remove()">&#10005;</button>
</div>
</div>
${syncStatus}
<div class="mix-modal-body" id="mix-modal-tracks"></div>
</div>
`;
document.body.appendChild(overlay);
renderCompactPlaylist(document.getElementById('mix-modal-tracks'), mix.tracks);
// 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
// poll/WebSocket tick refills the counters onto this fresh modal's elements.
if (mix.syncKey && discoverSyncPollers[mix.syncKey]) {
const statusEl = document.getElementById(`${base}-sync-status`);
if (statusEl) statusEl.style.display = 'block';
const btnEl = document.getElementById(`${base}-sync-btn`);
if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; btnEl.style.cursor = 'not-allowed'; }
}
}
async function blockDiscoveryArtist(artistName) {
if (!confirm(`Block "${artistName}" from all discovery playlists?`)) return;
try {
@ -7975,8 +8098,12 @@ async function loadDiscoveryShuffle() {
}
personalizedDiscoveryShuffle = data.tracks;
renderCompactPlaylist(container, data.tracks);
container.closest('.discover-section').style.display = 'block';
_upsertMixCard({
key: 'discovery_shuffle', title: 'Discovery Shuffle',
subtitle: 'A shuffle across everything we discovered for you',
tracks: data.tracks, syncKey: 'discovery_shuffle',
});
_collapseOldMixSection(container);
} catch (error) {
console.error('Error loading discovery shuffle:', error);

View file

@ -35006,6 +35006,95 @@ div.artist-hero-badge {
}
.ya-card-name:hover { color: var(--accent); }
/* Your Mixes (Spotify-style playlist cards, #discover redesign)
Each mix is ONE card: a 2x2 mosaic cover from its top tracks + name below, with
a hover play button. Click opens the track list in a modal. Replaces the old
full-width compact-track tables. */
.discover-mixes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
gap: 18px;
}
.discover-mix-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 14px;
padding: 12px;
cursor: pointer;
transition: transform 0.3s cubic-bezier(0.4,0,0.2,1), border-color 0.3s, box-shadow 0.3s, background 0.3s;
}
.discover-mix-card:hover {
transform: translateY(-5px);
background: rgba(255,255,255,0.06);
border-color: rgba(var(--accent-rgb),0.25);
box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 24px rgba(var(--accent-rgb),0.12);
}
.mix-card-cover {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
box-shadow: 0 6px 20px rgba(0,0,0,0.4);
}
.mix-card-tile {
background-size: cover;
background-position: center;
background-color: #1a1a1a;
}
.mix-card-play {
position: absolute;
bottom: 10px; right: 10px;
width: 44px; height: 44px;
border-radius: 50%;
background: var(--accent);
color: #000;
display: flex; align-items: center; justify-content: center;
font-size: 15px; padding-left: 2px;
box-shadow: 0 6px 16px rgba(0,0,0,0.45);
opacity: 0; transform: translateY(8px);
transition: opacity 0.25s, transform 0.25s;
z-index: 2;
}
.discover-mix-card:hover .mix-card-play {
opacity: 1; transform: translateY(0);
}
.mix-card-name {
font-size: 14px; font-weight: 700; color: #fff;
margin-top: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mix-card-meta {
font-size: 12px; color: rgba(255,255,255,0.5);
margin-top: 3px;
}
/* The mix modal — opened from a mix card; holds the track list + per-mix actions. */
.mix-modal {
background: #14141c; border-radius: 18px;
width: 760px; max-width: 94vw; max-height: 86vh;
display: flex; flex-direction: column;
box-shadow: 0 24px 70px rgba(0,0,0,0.6);
border: 1px solid rgba(255,255,255,0.08);
}
.mix-modal-header {
display: flex; justify-content: space-between; align-items: flex-start;
padding: 22px 24px; gap: 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.mix-modal-subtitle { font-size: 12px; color: #999; }
.mix-modal-title { font-size: 24px; font-weight: 700; color: #fff; margin: 4px 0; }
.mix-modal-meta { font-size: 12px; color: rgba(255,255,255,0.5); }
.mix-modal-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
.mix-modal-close {
background: none; border: none; color: rgba(255,255,255,0.5);
font-size: 16px; cursor: pointer; padding: 6px; line-height: 1;
}
.mix-modal-close:hover { color: #fff; }
.mix-modal-body { overflow-y: auto; padding: 16px 24px 24px; }
/* ── Artist Info Modal ── */
.ya-info-modal {
background: #141420; border-radius: 18px;