From 46e686ce223ac9ecb2500489a49f4010c7137364 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:21:19 -0400 Subject: [PATCH] Music: Featured tab with a random artist and album, from the whole library or chosen playlists - Featured is the first Music tab; picks stay for the day on a device, Pick again rolls new ones - Based on: choose the playlists they come from (synced across devices) - Play mix shuffles the artist's songs; Play plays the album Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 ++ public/app.js | 2 +- public/music.js | 66 ++++++++++++++++++++++++++++++++++++++++++++---- public/style.css | 8 ++++++ public/sw.js | 2 +- settings.js | 2 +- 6 files changed, 74 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e6c4814..eb15b5b 100644 --- a/README.md +++ b/README.md @@ -168,3 +168,5 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **My apps on Home:** the extensions a person installed from My Store, and any Sites in My Store, appear under *My apps* on Home and in the command palette. +**Music, Featured:** the first tab of Music shows a featured artist and a featured album, picked at random. They stay the same for the day on that device; *Pick again* rolls new ones. *Based on* chooses which playlists they come from (nothing chosen means the whole library), and that choice follows you between devices. *Play mix* shuffles the artist's songs; *Play* plays the album in order. + diff --git a/public/app.js b/public/app.js index 64cb571..1949290 100644 --- a/public/app.js +++ b/public/app.js @@ -10,7 +10,7 @@ const $ = s => document.querySelector(s); const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); // Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different // device, or the separate storage of the iOS home-screen app. Only the music queue stays local. -const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups']); +const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'music_feat_pls']); const outbox = new Map(); let outTimer = null, warned = false; function flushSettings() { outTimer = null; diff --git a/public/music.js b/public/music.js index 9dc817f..db17e83 100644 --- a/public/music.js +++ b/public/music.js @@ -110,24 +110,26 @@ async function plexPickServer() { } catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `

Music

${esc(e.message)}

`; } } -async function musicRoute([kind = 'albums', key], st) { +async function musicRoute([kind = 'featured', key], 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 = ['albums', 'artists', 'playlists', 'recent']; + const tabs = ['featured', 'albums', 'artists', 'playlists', 'recent']; const seg = `
${tabs.map(t => `${t[0].toUpperCase() + t.slice(1)}`).join('')}
`; const card = (href, thumb, title, sub, round) => ` ${esc(title)}${esc(sub)}`; const listing = (items, html) => { - v.innerHTML = `

Music

${esc(st.server)}${secs.length > 1 ? ` · ${esc(cur.title)} ▾` : ''}

${seg} -
${html || '

Nothing here

'}
`; + v.innerHTML = `

Music

${esc(st.server)}${secs.length > 1 ? ` · ${esc(cur.title)} ▾` : ''}

${seg}${items === 'raw' ? '' : ''} +
${html || '

Nothing here

'}
`; plexHomeBadge(st); 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(); } }))); }; - $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); }; + if ($('#mq')) $('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); }; }; const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort'; + if (kind === 'featured') return musicFeatured({ sec, card, listing, st }); + if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`); return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); } if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`); @@ -171,3 +173,57 @@ async function musicRoute([kind = 'albums', key], st) { } location.hash = '#/music/albums'; } + +// ---- Featured: an artist and an album picked at random, either from the whole library or only from the playlists you choose. The pick stays for the day on this +// device (so Music does not change every time you open it); "Pick again" rolls new ones, and "Based on" chooses the playlists. +const featKey = () => 'music_feat:' + px.serverKey, featPls = () => store.get('music_feat_pls', []); +const pick = a => a[Math.floor(Math.random() * a.length)]; +/** Two random choices: an artist { id, title, thumb } and an album { id, title, artist, thumb }. */ +async function featuredPick(sec, pls) { + if (pls.length) { // only what is in the chosen playlists + const lists = await Promise.all(pls.map(id => px.get(`/playlists/${id}/items`).catch(() => ({ Metadata: [] })))); + const artists = new Map(), albums = new Map(); + for (const t of lists.flatMap(l => l.Metadata || [])) { + if (t.grandparentRatingKey) artists.set(t.grandparentRatingKey, { id: t.grandparentRatingKey, title: t.grandparentTitle, thumb: t.grandparentThumb }); + if (t.parentRatingKey) albums.set(t.parentRatingKey, { id: t.parentRatingKey, title: t.parentTitle, artist: t.grandparentTitle || '', thumb: t.parentThumb || t.thumb }); + } + if (!artists.size && !albums.size) throw new Error('The playlists you chose have no songs in them yet'); + return { artist: artists.size ? pick([...artists.values()]) : null, album: albums.size ? pick([...albums.values()]) : null }; + } + const one = async type => { // a random item of a type: ask how many there are, then fetch one at a random place + const n = (await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=0&X-Plex-Container-Size=0`)).totalSize || 0; if (!n) return null; + const m = ((await px.get(`/library/sections/${sec}/all?type=${type}&X-Plex-Container-Start=${Math.floor(Math.random() * n)}&X-Plex-Container-Size=1`)).Metadata || [])[0]; + return m ? { id: m.ratingKey, title: m.title, artist: m.parentTitle || '', thumb: m.thumb } : null; + }; + const [artist, album] = await Promise.all([one(8), one(9)]); return { artist, album }; +} +async function musicFeatured({ sec, card, listing, st }) { + const day = new Date().toDateString(), pls = featPls(); let saved = store.get(featKey(), null); + const fresh = !saved || saved.day !== day || saved.sec !== sec || JSON.stringify(saved.pls) !== JSON.stringify(pls); + let p = fresh ? null : saved.p, err = ''; + if (!p) { try { p = await featuredPick(sec, pls); store.set(featKey(), { day, sec, pls, p }); } catch (e) { err = e.message; p = { artist: null, album: null }; } } + const names = pls.length ? (await px.get('/playlists?playlistType=audio').then(m => (m.Metadata || []).filter(x => pls.includes(x.ratingKey)).map(x => x.title)).catch(() => [])) : []; + const big = (kind, o, round) => `
Featured ${kind}

${esc(o.title)}

${o.artist ? `

${esc(o.artist)}

` : ''} +
Open
`; + listing('raw', `
+ ${err ? `

${esc(err)}

` : ''}
${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}
`); + const v = $('#view'); + v.querySelectorAll('[data-fp]').forEach(b => b.onclick = async () => { + b.disabled = true; + try { + if (b.dataset.fp === 'album') { const mc = await px.get(`/library/metadata/${p.album.id}/children`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(tr, 0); } + else { const mc = await px.get(`/library/metadata/${p.artist.id}/allLeaves`), tr = (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); if (tr.length) playQueue(shuffled(tr), 0, true); else toast('No songs found for this artist'); } + } catch (e) { toast(e.message); } + b.disabled = false; + }); + $('#fre').onclick = () => { store.del(featKey()); route(); }; + $('#fpl').onclick = async () => { + let all = []; try { all = ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(x => !x.smart || true); } catch (e) { return toast(e.message); } + if (!all.length) return toast('Make a playlist first, then choose it here'); + const chosen = new Set(pls), el = openSheet('Based on', 'Pick the playlists the featured artist and album come from. None chosen means your whole library.', `
${all.map(x => ``).join('')}
+
`); + const save = ids => { store.set('music_feat_pls', ids); store.del(featKey()); closeSheet(); route(); }; + el.querySelector('#fok').onclick = () => save([...el.querySelectorAll('[data-pl]:checked')].map(c => c.dataset.pl)); + el.querySelector('#fall').onclick = () => save([]); + }; +} diff --git a/public/style.css b/public/style.css index ccc878e..e2efa3e 100644 --- a/public/style.css +++ b/public/style.css @@ -976,3 +976,11 @@ body.vizopen { overflow: hidden; } /* Store */ #stbody .wrap { padding: 0; max-width: none; } #stbody .vimg img { width: 100%; height: 100%; object-fit: cover; } + +/* Music: featured artist and album */ +.featgrid { display: grid; grid-template-columns: 1fr; gap: 16px; } +@media (min-width: 760px) { .featgrid { grid-template-columns: 1fr 1fr; } } +.featcard { display: flex; gap: 16px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 16px; padding: 14px; } +.featcard .art { width: 150px; height: 150px; border-radius: 12px; object-fit: cover; flex: none; display: block; } .featcard .art.round { border-radius: 50%; } +.featinfo { min-width: 0; } .featinfo h2 { margin: 2px 0 0; font-size: 22px; text-transform: none; letter-spacing: 0; color: var(--text); overflow-wrap: anywhere; } +@media (max-width: 420px) { .featcard { flex-direction: column; align-items: flex-start; } .featcard .art { width: 100%; height: auto; aspect-ratio: 1 / 1; } } diff --git a/public/sw.js b/public/sw.js index 269e7c2..cdf327b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v55'; +const CACHE = 'hub-v56'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/settings.js b/settings.js index 4fdf766..2c8477a 100644 --- a/settings.js +++ b/settings.js @@ -9,7 +9,7 @@ const users = require('./users'); const file = () => path.join(users.dir(), 'settings.json'); // each person has their own // Only these keys are accepted. (The music queue stays in the browser.) -const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']); +const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'music_feat_pls', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']); const SECRET = new Set(['ab_creds', 'sf_token']); const MAX_VALUE = 20000; // bytes of JSON per key