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 = `
`; }
}
-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 = ``;
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)}
` : ''}
+
${icon('play')}${kind === 'artist' ? 'Play mix' : 'Play'} Open `;
+ listing('raw', `${icon('shuffle')}Pick again Based on: ${pls.length ? esc(names.slice(0, 2).join(', ') || pls.length + ' playlist' + (pls.length > 1 ? 's' : '')) + (names.length > 2 ? ' +' + (names.length - 2) : '') : 'Whole library'} ▾
+ ${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 => `${esc(x.title)} ${x.leafCount || 0} songs `).join('')}
+ Whole library Use these
`);
+ 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