${i + 1} ${esc(t.title)} ${esc(t.artist)} ${fmtDur(t.dur)}
@@ -229,13 +229,20 @@ async function musicStats({ v, st }, range = 'month') {
}
// ================================================================ Family playlists
-const famCard = p => `
${esc(p.name)} ${p.count} songs · ${esc(p.ownerName)} `;
+/** Plex-style cover for a playlist: the art of its songs in a 2x2 grid (one picture if they share one), or a colored tile when no song has art. */
+const mmMosaic = (thumbs, name, cls = '') => {
+ const u = [...new Set((thumbs || []).filter(Boolean))].slice(0, 4);
+ if (!u.length) return `
`;
+ const img = t => `
`;
+ return `
${(u.length < 4 ? u.slice(0, 1) : u).map(img).join('')} `;
+};
+const famCard = p => `
${mmMosaic(p.tracks.slice(-40).reverse().map(t => t.thumb), p.name)}${esc(p.name)} ${p.count} songs · ${esc(p.ownerName)} `;
async function musicFamilyPage({ v }, id) {
mmLoading(v, 'Family playlist');
let p; try { p = await mmApi('/family/' + id); } catch (e) { v.innerHTML = `
`; return; }
const tracks = await mmTracksByKeys(p.tracks.map(t => t.key)); if (!location.hash.startsWith('#/music/family')) return;
const mineOrAdmin = ME && (ME.role === 'admin' || ME.id === p.owner);
- mmPage(v, { title: p.name, sub: `${tracks.length} songs · a Family playlist by ${p.ownerName}. Everyone can add and remove songs.`, tracks, back: ['#/music/playlists', 'Playlists'], extra: `${mineOrAdmin ? '
Rename Delete ' : ''}`, onChange: () => {} });
+ mmPage(v, { title: p.name, cover: mmMosaic(tracks.map(t => t.thumb), p.name, 'art'), sub: `${tracks.length} songs · a Family playlist by ${p.ownerName}. Everyone can add and remove songs.`, tracks, back: ['#/music/playlists', 'Playlists'], extra: `${mineOrAdmin ? '
Rename Delete ' : ''}`, onChange: () => {} });
v.querySelectorAll('.row.it').forEach(r => { const t = tracks[+r.dataset.i], b = document.createElement('button'); b.className = 'ibtn'; b.setAttribute('aria-label', 'Remove from this playlist'); b.innerHTML = icon('trash'); b.onclick = async () => { try { await mmApi(`/family/${id}/tracks/${t.key}`, { method: 'DELETE' }); toast('Removed'); route(); } catch (e) { toast(e.message); } }; r.appendChild(b); });
if ($('#mfr')) $('#mfr').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await mmApi('/family/' + id, mmOpt('POST', { name: n })); route(); } catch (e) { toast(e.message); } }, p.name);
if ($('#mfd')) $('#mfd').onclick = () => confirmSheet('Delete this Family playlist?', 'It disappears for everyone.', 'Delete', async () => { try { await mmApi('/family/' + id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
diff --git a/public/music.js b/public/music.js
index 187f51a..fe353c5 100644
--- a/public/music.js
+++ b/public/music.js
@@ -115,13 +115,17 @@ async function musicRoute([kind = 'featured', key, key2], st) {
px.serverKey = st.server;
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
const tabs = ['featured', 'mixes', 'albums', 'artists', 'playlists', 'favorites', 'stats', 'recent'], TAB_NAME = { recent: 'New', stats: 'Stats' };
- const seg = `
`;
+ const tname = t => TAB_NAME[t] || t[0].toUpperCase() + t.slice(1), MAIN = ['featured', 'albums', 'artists', 'playlists'], MORE = tabs.filter(t => !MAIN.includes(t));
+ // wide screens: every tab in one row. phones: the four main tabs fill one row and the rest are behind "More"
+ const seg = `
+
`;
const card = (href, thumb, title, sub, round) => `
- ${esc(title)} ${esc(sub)} `;
+ ${thumb ? `
` : '
'}
${esc(title)} ${esc(sub)} `;
const listing = (items, html) => {
v.innerHTML = `
Music ${esc(st.server)}${secs.length > 1 ? ` · ${esc(cur.title)} ▾ ` : ''}
${seg}${items === 'raw' ? '' : '
'}
${html || '
Nothing here
'}
`;
plexHomeBadge(st);
+ const sm = $('#segmore'); if (sm) sm.onclick = e => { e.preventDefault(); actionSheet('Music', '', MORE.map(t => ({ label: tname(t) + (t === kind ? ' ✓' : ''), icon: 'music', run: () => { location.hash = '#/music/' + t; } }))); };
const pl2 = $('#plib');
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
diff --git a/public/style.css b/public/style.css
index 0d7ce62..574d413 100644
--- a/public/style.css
+++ b/public/style.css
@@ -146,8 +146,8 @@ body.editing #view { padding: 0; display: flex; flex-direction: column; }
/* Music */
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
-.seg { display: flex; gap: 6px; margin: 12px 0; overflow-x: auto; }
-.seg a { padding: 8px 16px; border-radius: 999px; background: var(--panel2); color: var(--muted); white-space: nowrap; min-height: 40px; display: flex; align-items: center; }
+.seg { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
+.seg a { padding: 8px 14px; border-radius: 999px; background: var(--panel2); color: var(--muted); white-space: nowrap; min-height: 40px; display: flex; align-items: center; }
.seg a.on { background: var(--accent); color: var(--accent-t); font-weight: 600; }
.agrid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.acard { display: block; min-width: 0; }
@@ -1160,3 +1160,13 @@ body.vfullwin:has(.vstage.vhide) .vstage.vfull { bottom: 0; } body.vfullwin:has(
.mprime .btn { flex: 1; justify-content: center; min-height: 48px; font-size: 16px; }
.msecond { margin-top: -4px; } .msecond:empty { display: none; }
.msecond .btn { min-height: 40px; font-size: 14px; }
+
+/* playlist cover: the songs' art in a grid, like Plex */
+.mosaic { display: grid; grid-template-columns: 1fr 1fr; width: 100%; aspect-ratio: 1; border-radius: 12px; overflow: hidden; background: var(--panel2); }
+.mosaic img { width: 100%; height: 100%; object-fit: cover; display: block; } .mosaic.one { grid-template-columns: 1fr; }
+.mhead .mosaic { width: 38vw; max-width: 200px; flex: none; border-radius: 14px; } .acard .mixart { display: block; }
+/* Now Playing sheet: the close button sits over the corner instead of pushing the art aside */
+#sheet .sheet.npsheet .sheetx { float: none; display: grid; place-items: center; margin: 0 0 -36px auto; background: rgba(0, 0, 0, .45); backdrop-filter: blur(6px); color: #fff; }
+
+.seg.segphone { display: none; }
+@media (max-width: 640px) { .seg.segwide { display: none; } .seg.segphone { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; } .seg.segphone a { padding: 0 2px; justify-content: center; font-size: 13px; min-width: 0; overflow: hidden; text-overflow: ellipsis; } }