diff --git a/public/music-more.js b/public/music-more.js index 4ad7184..a639780 100644 --- a/public/music-more.js +++ b/public/music-more.js @@ -50,9 +50,9 @@ async function mmRandomTracks(sec, n, pls = []) { } // ---- a page of songs (a mix, a genre, favorites...): Play, Shuffle, Save as playlist, and the list -function mmPage(v, { title, sub, thumb, tracks, back, about, extra = '', onChange }) { +function mmPage(v, { title, sub, thumb, cover, tracks, back, about, extra = '', onChange }) { const hrefBack = back ? `
${icon('back')}${esc(back[1])}
` : ''; - v.innerHTML = `
${hrefBack}
${thumb ? `` : `
`}

${esc(title)}

${esc(sub || tracks.length + ' songs')}

+ v.innerHTML = `
${hrefBack}
${cover ? cover : thumb ? `` : `
`}

${esc(title)}

${esc(sub || tracks.length + ' songs')}

${about ? `
About

${esc(about).replace(/\n/g, '
')}

` : ''}
${extra}
${tracks.map((t, i) => `
@@ -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 = `

${esc(e.message)}

`; 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 ? '' : ''}`, 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 ? '' : ''}`, 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 = `
${tabs.map(t => `${tname(t)}`).join('')}
+ `; 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; } }