Music: Featured tab with a random artist and album, from the whole library or chosen playlists
build-container / build (push) Successful in 7m35s
build-container / build (push) Successful in 7m35s
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
dc8265a850
commit
46e686ce22
6 files changed
+74
-8
No files matched your search
+1
-1
@@ -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;
|
||||
|
||||
+61
-5
@@ -110,24 +110,26 @@ async function plexPickServer() {
|
||||
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
|
||||
}
|
||||
|
||||
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 = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
|
||||
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
|
||||
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
|
||||
const listing = (items, html) => {
|
||||
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
|
||||
<div class="agrid" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
|
||||
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}<span id="phu"></span></p>${seg}${items === 'raw' ? '' : '<input type="search" id="mq" placeholder="Filter" autocomplete="off">'}
|
||||
<div class="${items === 'raw' ? '' : 'agrid'}" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
|
||||
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) => `<div class="featcard"><a href="#/music/${kind}/${esc(o.id)}"><img class="art ${round ? 'round' : ''}" alt="" src="${px.art(o.thumb, 400)}"></a><div class="featinfo"><small class="sub">Featured ${kind}</small><h2>${esc(o.title)}</h2>${o.artist ? `<p class="sub">${esc(o.artist)}</p>` : ''}
|
||||
<div class="bar" style="justify-content:flex-start"><button class="btn" data-fp="${kind}">${icon('play')}${kind === 'artist' ? 'Play mix' : 'Play'}</button><a class="btn ghost" href="#/music/${kind}/${esc(o.id)}">Open</a></div></div></div>`;
|
||||
listing('raw', `<div class="bar" style="justify-content:flex-start;margin:0 0 10px;flex-wrap:wrap"><button class="btn ghost" id="fre">${icon('shuffle')}Pick again</button><button class="btn ghost" id="fpl">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'} ▾</button></div>
|
||||
${err ? `<p class="err">${esc(err)}</p>` : ''}<div class="featgrid">${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}</div>`);
|
||||
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.', `<div class="list" style="max-height:50dvh;overflow:auto">${all.map(x => `<label class="row" style="cursor:pointer"><input type="checkbox" data-pl="${esc(x.ratingKey)}"${chosen.has(x.ratingKey) ? ' checked' : ''}><span class="grow"><b>${esc(x.title)}</b><small>${x.leafCount || 0} songs</small></span></label>`).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="fall" style="flex:1;justify-content:center">Whole library</button><button class="btn" id="fok" style="flex:1;justify-content:center">Use these</button></div>`);
|
||||
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([]);
|
||||
};
|
||||
}
|
||||
@@ -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; } }
|
||||
+1
-1
@@ -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'];
|
||||
|
||||
|
||||
Reference in new issue
Block a user