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:
parent
4b8ddad9ff
commit
9d5e58c4b8
3 changed files with 236 additions and 8 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">▶</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">⟳</span><span>Syncing to media server...</span></div>
|
||||
<div class="sync-status-stats">
|
||||
<span class="sync-stat">✓ <span id="${base}-sync-completed">0</span></span>
|
||||
<span class="sync-stat">⏳ <span id="${base}-sync-pending">0</span></span>
|
||||
<span class="sync-stat">✗ <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()">✕</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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue