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>
|
</div>
|
||||||
|
|
||||||
<!-- #913: Listening Mix — a playable track mix from those recommended artists -->
|
<!-- #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" style="display: none;">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -4278,8 +4278,12 @@ async function loadPersonalizedPopularPicks() {
|
||||||
}
|
}
|
||||||
|
|
||||||
personalizedPopularPicks = data.tracks;
|
personalizedPopularPicks = data.tracks;
|
||||||
renderCompactPlaylist(container, data.tracks);
|
_upsertMixCard({
|
||||||
container.closest('.discover-section').style.display = 'block';
|
key: 'popular_picks', title: 'Popular Picks',
|
||||||
|
subtitle: 'Popular tracks from artists you love',
|
||||||
|
tracks: data.tracks, syncKey: 'popular_picks',
|
||||||
|
});
|
||||||
|
_collapseOldMixSection(container);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading popular picks:', error);
|
console.error('Error loading popular picks:', error);
|
||||||
|
|
@ -4301,8 +4305,12 @@ async function loadPersonalizedHiddenGems() {
|
||||||
}
|
}
|
||||||
|
|
||||||
personalizedHiddenGems = data.tracks;
|
personalizedHiddenGems = data.tracks;
|
||||||
renderCompactPlaylist(container, data.tracks);
|
_upsertMixCard({
|
||||||
container.closest('.discover-section').style.display = 'block';
|
key: 'hidden_gems', title: 'Hidden Gems',
|
||||||
|
subtitle: 'Deeper cuts you might have missed',
|
||||||
|
tracks: data.tracks, syncKey: 'hidden_gems',
|
||||||
|
});
|
||||||
|
_collapseOldMixSection(container);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading hidden gems:', error);
|
console.error('Error loading hidden gems:', error);
|
||||||
|
|
@ -4327,8 +4335,14 @@ async function loadPersonalizedListeningMix() {
|
||||||
}
|
}
|
||||||
|
|
||||||
personalizedListeningMix = data.tracks;
|
personalizedListeningMix = data.tracks;
|
||||||
renderCompactPlaylist(container, data.tracks);
|
// Spotify-style: collapse this mix into a card in the "Your Mixes" shelf; its
|
||||||
container.closest('.discover-section').style.display = 'block';
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Error loading listening mix:', error);
|
console.error('Error loading listening mix:', error);
|
||||||
|
|
@ -4410,6 +4424,115 @@ function renderCompactPlaylist(container, tracks) {
|
||||||
container.innerHTML = html;
|
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) {
|
async function blockDiscoveryArtist(artistName) {
|
||||||
if (!confirm(`Block "${artistName}" from all discovery playlists?`)) return;
|
if (!confirm(`Block "${artistName}" from all discovery playlists?`)) return;
|
||||||
try {
|
try {
|
||||||
|
|
@ -7975,8 +8098,12 @@ async function loadDiscoveryShuffle() {
|
||||||
}
|
}
|
||||||
|
|
||||||
personalizedDiscoveryShuffle = data.tracks;
|
personalizedDiscoveryShuffle = data.tracks;
|
||||||
renderCompactPlaylist(container, data.tracks);
|
_upsertMixCard({
|
||||||
container.closest('.discover-section').style.display = 'block';
|
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) {
|
} catch (error) {
|
||||||
console.error('Error loading discovery shuffle:', error);
|
console.error('Error loading discovery shuffle:', error);
|
||||||
|
|
|
||||||
|
|
@ -35006,6 +35006,95 @@ div.artist-hero-badge {
|
||||||
}
|
}
|
||||||
.ya-card-name:hover { color: var(--accent); }
|
.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 ── */
|
/* ── Artist Info Modal ── */
|
||||||
.ya-info-modal {
|
.ya-info-modal {
|
||||||
background: #141420; border-radius: 18px;
|
background: #141420; border-radius: 18px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue