build-container / build (push) Successful in 6m21s
- Mixes tab: daily mix, most played, forgotten favorites, moods, on this day, decades, genres, smart playlists (rules), library radio - Favorites (hearts, kept by the hub) and Stats (plays, time, top artists/songs/albums, streak, recently played) - Family playlists shared by everyone with Music; queue reorder; More like this; album/artist About notes - Save albums offline; player takes colors from the cover; Share a lyric line as a picture; verse of the day on Featured - musicdata.js keeps plays, favorites, smart rules and Family playlists, with tests Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
320 lines
36 KiB
JavaScript
320 lines
36 KiB
JavaScript
'use strict';
|
||
// ---- Music, more: history and stats, favorites, mixes (daily, most played, forgotten, moods, on this day, decades, genres), smart playlists, Family playlists, library radio,
|
||
// cover-art colors, offline albums, and a verse for the day. Uses the globals from music.js / player.js (px, pl, mapTrack, playQueue, listing helpers) and the hub's /api/music.
|
||
const mmApi = (p, o) => jfetch('/api/music' + p, o);
|
||
const mmOpt = (m, b) => ({ method: m, headers: { 'content-type': 'application/json' }, body: b === undefined ? undefined : JSON.stringify(b) });
|
||
const mmPlex = k => /^\d{1,12}$/.test(String(k || ''));
|
||
const mmMeta = t => ({ key: t.key, title: t.title, artist: t.artist, album: t.album, thumb: t.thumb, artistKey: t.artistKey, albumKey: t.albumKey, dur: t.dur });
|
||
const mmPick = (a, n) => shuffled(a).slice(0, n);
|
||
const mmHours = m => m >= 90 ? (m / 60).toFixed(m >= 600 ? 0 : 1) + ' h' : m + ' min';
|
||
const mmNow = () => Math.floor(Date.now() / 1000);
|
||
|
||
// ---- what was played: a song that has been played for half its length (or four minutes) is counted, the same moment Plex is told
|
||
function musicPlayed(t, secs) {
|
||
if (!t || !mmPlex(t.key) || !allowed('music')) return;
|
||
fetch('/api/music/played', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...mmMeta(t), dur: secs ? secs * 1000 : t.dur }), keepalive: true }).catch(() => {});
|
||
}
|
||
|
||
// ---- favorites (kept by the hub, so they follow you between devices)
|
||
const mf = { keys: new Set(), list: null };
|
||
async function mfLoad(force) { if (mf.list && !force) return mf.list; try { mf.list = await mmApi('/favs'); mf.keys = new Set(mf.list.map(x => x.key)); } catch { mf.list = mf.list || []; } return mf.list; }
|
||
const mfIs = t => !!t && mf.keys.has(String(t.key));
|
||
async function mfToggle(t) {
|
||
if (!mmPlex(t.key)) return toast('Only songs from your music library can be favorites');
|
||
try {
|
||
if (mfIs(t)) { await mmApi('/favs/' + t.key, { method: 'DELETE' }); mf.keys.delete(String(t.key)); toast('Removed from Favorites'); }
|
||
else { await mmApi('/favs', mmOpt('POST', mmMeta(t))); mf.keys.add(String(t.key)); toast('Added to Favorites'); }
|
||
mf.list = null; mfPaint();
|
||
} catch (e) { toast(e.message); }
|
||
}
|
||
/** Hearts on screen (the Now Playing sheet and the track rows) follow what is a favorite. */
|
||
function mfPaint() {
|
||
const t = typeof cur === 'function' ? cur() : null;
|
||
document.querySelectorAll('#npfav, #cnfav').forEach(b => { const on = mfIs(t); b.classList.toggle('on', on); b.style.color = on ? 'var(--danger)' : ''; b.setAttribute('aria-pressed', on); });
|
||
document.querySelectorAll('[data-fv]').forEach(b => { const on = mf.keys.has(b.dataset.fv); b.style.color = on ? 'var(--danger)' : ''; b.setAttribute('aria-pressed', on); });
|
||
}
|
||
|
||
// ---- songs by their Plex keys (the hub remembers keys; the song itself, with its file, comes from Plex)
|
||
async function mmTracksByKeys(keys) {
|
||
keys = [...new Set(keys.map(String))].filter(mmPlex); const out = new Map();
|
||
for (let i = 0; i < keys.length; i += 40) {
|
||
try { const mc = await px.get('/library/metadata/' + keys.slice(i, i + 40).join(',')); for (const t of mc.Metadata || []) { const m = mapTrack(t, mc); if (m) out.set(String(t.ratingKey), m); } } catch { /* a missing song is simply left out */ }
|
||
}
|
||
return keys.map(k => out.get(k)).filter(Boolean);
|
||
}
|
||
const mmTracksOf = async albumKeys => (await Promise.all(albumKeys.map(async k => { try { const mc = await px.get(`/library/metadata/${k}/children`); return (mc.Metadata || []).map(t => mapTrack(t, mc)).filter(Boolean); } catch { return []; } }))).flat();
|
||
/** Random songs from the library, or only from the chosen playlists. */
|
||
async function mmRandomTracks(sec, n, pls = []) {
|
||
if (pls.length) { const lists = await Promise.all(pls.map(id => px.get(`/playlists/${id}/items`).catch(() => ({ Metadata: [] })))); return mmPick(lists.flatMap(l => (l.Metadata || []).map(t => mapTrack(t, l)).filter(Boolean)), n); }
|
||
try { return ((await px.get(`/library/sections/${sec}/all?type=10&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=${n}`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean); } catch { return []; }
|
||
}
|
||
|
||
// ---- 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 }) {
|
||
const hrefBack = back ? `<div class="bar"><a class="btn ghost" href="${back[0]}">${icon('back')}${esc(back[1])}</a></div>` : '';
|
||
v.innerHTML = `<div class="wrap">${hrefBack}<div class="mhead">${thumb ? `<img class="art" alt="" src="${px.art(thumb, 400)}">` : `<div class="art mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"></div>`}<div><h1>${esc(title)}</h1><p class="sub">${esc(sub || tracks.length + ' songs')}</p></div></div>
|
||
${about ? `<details class="mmabout"><summary>About</summary><p>${esc(about).replace(/\n/g, '<br>')}</p></details>` : ''}
|
||
<div class="bar"><button class="btn" id="mpa">${icon('play')}Play</button><button class="btn ghost" id="mps">${icon('shuffle')}Shuffle</button><button class="btn ghost" id="mpl">${icon('plus')}Save as playlist</button>${extra}</div>
|
||
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span><small>${fmtDur(t.dur)}</small></button>
|
||
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Song actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable songs</p>'}</div></div>`;
|
||
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
|
||
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m]));
|
||
v.querySelectorAll('button[data-fi]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.fi]; mfToggle(t).then(() => { if (onChange) onChange(); }); });
|
||
v.querySelector('#mpa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||
v.querySelector('#mps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||
v.querySelector('#mpl').onclick = () => tracks.length && addToPlaylist(tracks, title);
|
||
mfLoad().then(mfPaint); markPlaying();
|
||
}
|
||
const mmHue = s => { let h = 0; for (const ch of String(s)) h = (h * 31 + ch.charCodeAt(0)) % 360; return h; };
|
||
const mmLoading = (v, title) => { v.innerHTML = `<div class="wrap"><h1>${esc(title)}</h1><p class="empty">Putting it together…</p></div>`; };
|
||
|
||
// ================================================================ the Mixes tab
|
||
const MOODS = [
|
||
['calm', 'Calm', /ambient|classical|acoustic|folk|instrumental|new age|piano|soft|easy|lullaby|worship|hymn/i],
|
||
['upbeat', 'Upbeat', /pop|dance|rock|gospel|praise|funk|disco|swing|country|r&b|soul|reggae|latin/i],
|
||
['focus', 'Focus', /instrumental|classical|ambient|jazz|soundtrack|score|piano|lo-?fi/i],
|
||
['worship', 'Worship', /worship|christian|gospel|hymn|praise|ccm|inspirational/i],
|
||
];
|
||
let mmGenres = null, mmAlbumsCache = null;
|
||
async function mmGenreList(sec) { if (mmGenres && mmGenres.sec === sec) return mmGenres.list; try { const l = ((await px.get(`/library/sections/${sec}/genre`)).Directory || []).map(g => ({ key: g.key, title: g.title })); mmGenres = { sec, list: l }; return l; } catch { return []; } }
|
||
async function mmAllAlbums(sec) { if (mmAlbumsCache && mmAlbumsCache.sec === sec && Date.now() - mmAlbumsCache.t < 600000) return mmAlbumsCache.list; const mc = await px.get(`/library/sections/${sec}/all?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=3000`); mmAlbumsCache = { sec, t: Date.now(), list: mc.Metadata || [] }; return mmAlbumsCache.list; }
|
||
|
||
/** Build the songs of a mix: { title, sub, tracks }. */
|
||
async function mmBuildMix(sec, id, arg) {
|
||
if (id === 'daily') {
|
||
const [top, favs, rnd] = await Promise.all([mmApi('/top?days=60&limit=40').catch(() => []), mfLoad(true), mmRandomTracks(sec, 24)]);
|
||
const keys = [...mmPick(top, 14).map(x => x.key), ...mmPick(favs, 12).map(x => x.key)], known = await mmTracksByKeys(keys), seen = new Set(known.map(t => t.key));
|
||
const tracks = shuffled([...known, ...rnd.filter(t => !seen.has(t.key))]);
|
||
return { title: 'Daily mix', sub: new Date().toLocaleDateString([], { weekday: 'long' }) + ' · ' + tracks.length + ' songs from what you play, your favorites and a few surprises', tracks };
|
||
}
|
||
if (id === 'mostplayed') { const top = await mmApi('/top?days=30&limit=50'); return { title: 'Most played this month', tracks: await mmTracksByKeys(top.map(x => x.key)), sub: top.length ? '' : 'Play a few songs and this fills in' }; }
|
||
if (id === 'forgotten') {
|
||
const cutoff = mmNow() - 90 * 86400; let l = [];
|
||
try { l = ((await px.get(`/library/sections/${sec}/all?type=10&viewCount>>=2&lastViewedAt<<=${cutoff}&sort=viewCount:desc&X-Plex-Container-Start=0&X-Plex-Container-Size=200`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean); } catch { /* none */ }
|
||
const favs = await mfLoad(true), old = new Set(l.map(t => t.key)); const fav = (await mmTracksByKeys(mmPick(favs, 30).map(x => x.key))).filter(t => !old.has(t.key));
|
||
return { title: 'Forgotten favorites', sub: 'Songs you loved and have not played in a while', tracks: shuffled([...mmPick(l, 40), ...fav.slice(0, 15)]) };
|
||
}
|
||
if (id === 'mood') {
|
||
const mood = MOODS.find(m => m[0] === arg); if (!mood) throw new Error('Unknown mix');
|
||
const keys = (await mmGenreList(sec)).filter(g => mood[2].test(g.title)).map(g => g.key); if (!keys.length) throw new Error('No ' + mood[1].toLowerCase() + ' music was found in your genres');
|
||
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&genre=${keys.join(',')}&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=14`)).Metadata || []);
|
||
return { title: mood[1] + ' mix', sub: 'From your ' + mood[1].toLowerCase() + ' genres', tracks: mmPick(await mmTracksOf(albums.map(a => a.ratingKey)), 60) };
|
||
}
|
||
if (id === 'similar') { // more like an artist: the artists Plex says are similar, else the same genre
|
||
let artists = []; try { artists = ((await px.get(`/library/metadata/${arg}/similar`)).Metadata || []).slice(0, 8); } catch { /* none */ }
|
||
const me = ((await px.get(`/library/metadata/${arg}`)).Metadata || [])[0] || {};
|
||
if (!artists.length && me.Genre && me.Genre.length) { const g = ((await px.get(`/library/sections/${sec}/genre`)).Directory || []).find(d => d.title === me.Genre[0].tag); if (g) artists = ((await px.get(`/library/sections/${sec}/all?type=8&genre=${g.key}&sort=random&X-Plex-Container-Start=0&X-Plex-Container-Size=8`)).Metadata || []).filter(a => a.ratingKey !== arg); }
|
||
if (!artists.length) throw new Error('Plex has no similar artists for this one yet');
|
||
const albums = (await Promise.all(artists.slice(0, 6).map(async a => { try { return ((await px.get(`/library/metadata/${a.ratingKey}/children`)).Metadata || []).slice(0, 2); } catch { return []; } }))).flat();
|
||
return { title: 'More like ' + (me.title || 'this artist'), sub: artists.map(a => a.title).slice(0, 4).join(', '), tracks: mmPick(await mmTracksOf(albums.map(a => a.ratingKey)), 60) };
|
||
}
|
||
throw new Error('Unknown mix');
|
||
}
|
||
|
||
async function musicMixes({ sec, listing, v }) {
|
||
const [genres, smart] = await Promise.all([mmGenreList(sec), mmApi('/smart').catch(() => [])]);
|
||
const tile = (href, title, sub) => `<a class="acard mixtile" href="${href}"><span class="mixart" style="background:linear-gradient(135deg,hsl(${mmHue(title)} 55% 42%),hsl(${mmHue(title) + 40} 55% 26%))"><b>${esc(title)}</b></span><small>${esc(sub)}</small></a>`;
|
||
const moods = MOODS.filter(m => genres.some(g => m[2].test(g.title)));
|
||
const decades = [1960, 1970, 1980, 1990, 2000, 2010, 2020];
|
||
let today = [];
|
||
try { const d = new Date(), md = String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); today = (await mmAllAlbums(sec)).filter(a => (a.originallyAvailableAt || '').slice(5) === md && +(a.originallyAvailableAt || '0').slice(0, 4) < d.getFullYear()).sort((a, b) => (a.originallyAvailableAt < b.originallyAvailableAt ? -1 : 1)).slice(0, 12); } catch { /* skip it */ }
|
||
const card = (a, sub) => `<a class="acard" href="#/music/album/${a.ratingKey}"><img class="art" loading="lazy" alt="" src="${px.art(a.thumb)}"><b>${esc(a.title)}</b><small>${esc(sub)}</small></a>`;
|
||
listing('raw', `<div class="bar" style="justify-content:flex-start;flex-wrap:wrap;margin-top:0"><button class="btn" id="mxr">${icon('shuffle')}Library radio</button><small class="sub">An endless shuffle of your music. It uses the playlists chosen on Featured, if you chose any.</small></div>
|
||
<h2>Mixes for you</h2><div class="agrid">${tile('#/music/mix/daily', 'Daily mix', 'Your music, your favorites, a few surprises')}${tile('#/music/mix/mostplayed', 'Most played', 'This month')}${tile('#/music/mix/forgotten', 'Forgotten favorites', 'Not heard in a while')}${tile('#/music/favorites', 'Favorites', 'The songs you hearted')}</div>
|
||
${moods.length ? `<h2>Moods</h2><div class="agrid">${moods.map(m => tile('#/music/mix/mood/' + m[0], m[1], 'A mix of ' + m[1].toLowerCase() + ' music')).join('')}</div>` : ''}
|
||
${today.length ? `<h2>On this day</h2><p class="sub">Albums released on ${new Date().toLocaleDateString([], { month: 'long', day: 'numeric' })} in past years</p><div class="agrid">${today.map(a => card(a, (a.originallyAvailableAt || '').slice(0, 4) + ' · ' + (a.parentTitle || ''))).join('')}</div>` : ''}
|
||
<h2>Decades</h2><div class="seg">${decades.map(d => `<a href="#/music/decade/${d}">${d}s</a>`).join('')}</div>
|
||
${genres.length ? `<h2>Genres</h2><div class="seg" style="flex-wrap:wrap">${genres.slice(0, 40).map(g => `<a href="#/music/genre/${esc(g.key)}">${esc(g.title)}</a>`).join('')}</div>` : ''}
|
||
<h2>Smart playlists</h2><p class="sub">Rules that pick songs for you (for example: gospel from the 80s you have not played in a year).</p>
|
||
<div class="agrid">${smart.map(s => tile('#/music/smart/' + s.id, s.name, 'Smart playlist')).join('')}</div><div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="mxs">${icon('plus')}New smart playlist</button></div>`);
|
||
$('#mxr').onclick = () => mmRadioStart(sec);
|
||
$('#mxs').onclick = () => mmSmartEditor(sec, null, genres);
|
||
}
|
||
|
||
// ---- mix, genre, decade and smart playlist pages
|
||
async function musicMixPage({ sec, v }, id, arg, arg2) {
|
||
if (id === 'mood' || id === 'similar') { arg = arg; }
|
||
mmLoading(v, 'Mix');
|
||
try {
|
||
const m = await mmBuildMix(sec, id, id === 'mood' || id === 'similar' ? arg : arg);
|
||
if (!location.hash.startsWith('#/music/mix')) return;
|
||
mmPage(v, { title: m.title, sub: m.sub, tracks: m.tracks, back: ['#/music/mixes', 'Mixes'], extra: `<button class="btn ghost" id="mpr">${icon('repeat')}New ${esc(id === 'daily' ? 'mix' : 'pick')}</button>` });
|
||
v.querySelector('#mpr').onclick = () => musicMixPage({ sec, v }, id, arg);
|
||
} catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>Mix</h1><p class="err">${esc(e.message)}</p></div>`; }
|
||
}
|
||
async function musicGenrePage({ sec, v, card }, key) {
|
||
mmLoading(v, 'Genre');
|
||
const genres = await mmGenreList(sec), g = genres.find(x => String(x.key) === String(key)); if (!g) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><p class="err">That genre is not in your library.</p></div>`; return; }
|
||
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&genre=${encodeURIComponent(key)}&sort=titleSort&X-Plex-Container-Start=0&X-Plex-Container-Size=600`)).Metadata || []);
|
||
if (!location.hash.startsWith('#/music/genre')) return; const feat = albums.length ? albums[Math.floor(Math.random() * albums.length)] : null;
|
||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>${esc(g.title)}</h1><p class="sub">${albums.length} albums</p>
|
||
<div class="bar"><button class="btn" id="mgs">${icon('shuffle')}Shuffle ${esc(g.title)}</button></div>
|
||
${feat ? `<h2>Featured album</h2><div class="agrid"><a class="acard" href="#/music/album/${feat.ratingKey}"><img class="art" alt="" src="${px.art(feat.thumb)}"><b>${esc(feat.title)}</b><small>${esc(feat.parentTitle || '')}</small></a></div>` : ''}
|
||
<h2>Albums</h2><div class="agrid">${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')}</div></div>`;
|
||
$('#mgs').onclick = async e => { e.currentTarget.disabled = true; const t = mmPick(await mmTracksOf(mmPick(albums, 10).map(a => a.ratingKey)), 80); e.currentTarget.disabled = false; if (t.length) playQueue(shuffled(t), 0, true); else toast('No songs found'); };
|
||
}
|
||
async function musicDecadePage({ sec, v, card }, start) {
|
||
start = +start; if (!(start >= 1900 && start <= 2090)) return (location.hash = '#/music/mixes');
|
||
mmLoading(v, start + 's');
|
||
const albums = ((await px.get(`/library/sections/${sec}/all?type=9&year>>=${start}&year<<=${start + 9}&sort=year,titleSort&X-Plex-Container-Start=0&X-Plex-Container-Size=600`)).Metadata || []);
|
||
if (!location.hash.startsWith('#/music/decade')) return;
|
||
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><h1>${start}s</h1><p class="sub">${albums.length} albums</p>
|
||
<div class="bar"><button class="btn" id="mgs">${icon('shuffle')}Shuffle the ${start}s</button></div><div class="agrid">${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, (a.year || '') + ' · ' + (a.parentTitle || ''))).join('') || '<p class="empty">Nothing from this decade.</p>'}</div></div>`;
|
||
$('#mgs').onclick = async e => { e.currentTarget.disabled = true; const t = mmPick(await mmTracksOf(mmPick(albums, 10).map(a => a.ratingKey)), 80); e.currentTarget.disabled = false; if (t.length) playQueue(shuffled(t), 0, true); else toast('No songs found'); };
|
||
}
|
||
|
||
// ---- smart playlists
|
||
const SMART_ORDER = { random: 'random', newest: 'addedAt:desc', mostPlayed: 'viewCount:desc', leastRecent: 'lastViewedAt:asc' };
|
||
async function mmSmartTracks(sec, r) {
|
||
if (r.onlyFavorites) { const f = await mfLoad(true), keys = (r.order === 'random' ? shuffled(f) : f).slice(0, r.limit).map(x => x.key); return mmTracksByKeys(keys); }
|
||
const genres = await mmGenreList(sec), ids = r.genres.map(n => (genres.find(g => g.title === n) || {}).key).filter(Boolean);
|
||
const q = [`type=10`, `sort=${SMART_ORDER[r.order]}`, 'X-Plex-Container-Start=0', `X-Plex-Container-Size=${r.limit}`];
|
||
if (r.yearFrom) q.push(`year>>=${r.yearFrom}`); if (r.yearTo) q.push(`year<<=${r.yearTo}`); if (r.addedDays) q.push(`addedAt>>=${mmNow() - r.addedDays * 86400}`);
|
||
if (r.notPlayedDays) q.push(`lastViewedAt<<=${mmNow() - r.notPlayedDays * 86400}`); if (r.minPlays) q.push(`viewCount>>=${r.minPlays}`);
|
||
const run = async extra => ((await px.get(`/library/sections/${sec}/all?${[...q, ...extra].join('&')}`)).Metadata || []).map(t => mapTrack(t, {})).filter(Boolean);
|
||
if (!ids.length) return run([]);
|
||
let l = await run(['album.genre=' + ids.join(',')]); if (!l.length) l = await run(['artist.genre=' + ids.join(',')]); return l;
|
||
}
|
||
async function musicSmartPage({ sec, v }, id) {
|
||
mmLoading(v, 'Smart playlist');
|
||
const pl0 = (await mmApi('/smart')).find(s => s.id === id); if (!pl0) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/mixes">${icon('back')}Mixes</a></div><p class="err">That smart playlist is gone.</p></div>`; return; }
|
||
const tracks = await mmSmartTracks(sec, pl0.rules); if (!location.hash.startsWith('#/music/smart')) return;
|
||
mmPage(v, { title: pl0.name, sub: tracks.length + ' songs · a smart playlist', tracks, back: ['#/music/mixes', 'Mixes'], extra: `<button class="btn ghost" id="mse">Edit rules</button><button class="btn ghost" id="msd">Delete</button>` });
|
||
v.querySelector('#mse').onclick = async () => mmSmartEditor(sec, pl0, await mmGenreList(sec));
|
||
v.querySelector('#msd').onclick = () => confirmSheet('Delete this smart playlist?', pl0.name, 'Delete', async () => { try { await mmApi('/smart/' + id, { method: 'DELETE' }); toast('Deleted'); location.hash = '#/music/mixes'; } catch (e) { toast(e.message); } });
|
||
}
|
||
function mmSmartEditor(sec, sp, genres) {
|
||
const r = sp ? sp.rules : { genres: [], yearFrom: 0, yearTo: 0, addedDays: 0, notPlayedDays: 0, minPlays: 0, onlyFavorites: false, order: 'random', limit: 50 }, num = (id, label, val, ph) => `<label for="${id}">${label}</label><input type="number" id="${id}" min="0" value="${val || ''}" placeholder="${ph || 'any'}">`;
|
||
const el = openSheet(sp ? 'Edit smart playlist' : 'New smart playlist', 'Songs are picked by these rules each time you open it.', `<form id="msf"><label for="msn">Name</label><input type="text" id="msn" value="${esc(sp ? sp.name : '')}" maxlength="60" required autocomplete="off">
|
||
<label class="row" style="cursor:pointer"><span class="grow"><b>Only my favorites</b><small>Ignores the other rules except the order and the number of songs</small></span><input type="checkbox" id="msfv"${r.onlyFavorites ? ' checked' : ''}></label>
|
||
${genres.length ? `<label>Genres</label><div class="seg" id="msg" style="flex-wrap:wrap">${genres.slice(0, 40).map(g => `<a href="#" data-g="${esc(g.title)}" class="${r.genres.includes(g.title) ? 'on' : ''}">${esc(g.title)}</a>`).join('')}</div>` : ''}
|
||
<div class="bar" style="gap:10px;flex-wrap:nowrap"><div style="flex:1">${num('msy1', 'From year', r.yearFrom, '1980')}</div><div style="flex:1">${num('msy2', 'To year', r.yearTo, '1989')}</div></div>
|
||
${num('msa', 'Added in the last (days)', r.addedDays)}${num('msnp', 'Not played in (days)', r.notPlayedDays)}${num('msmp', 'Played at least (times)', r.minPlays)}
|
||
<label for="mso">Order</label><select id="mso">${[['random', 'Random'], ['newest', 'Newest added first'], ['mostPlayed', 'Most played first'], ['leastRecent', 'Not played for longest first']].map(([k, l]) => `<option value="${k}"${r.order === k ? ' selected' : ''}>${l}</option>`).join('')}</select>
|
||
${num('msl', 'Number of songs', r.limit, '50')}<div class="err" id="mse2"></div>
|
||
<div class="bar"><button class="btn ghost" type="button" id="msx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Save</button></div></form>`);
|
||
el.querySelector('#msx').onclick = closeSheet;
|
||
el.querySelectorAll('#msg [data-g]').forEach(a => a.onclick = e => { e.preventDefault(); a.classList.toggle('on'); });
|
||
el.querySelector('#msf').onsubmit = async e => {
|
||
e.preventDefault(); const g = id => +el.querySelector(id).value || 0;
|
||
const rules = { genres: [...el.querySelectorAll('#msg .on')].map(a => a.dataset.g), yearFrom: g('#msy1'), yearTo: g('#msy2'), addedDays: g('#msa'), notPlayedDays: g('#msnp'), minPlays: g('#msmp'), onlyFavorites: el.querySelector('#msfv').checked, order: el.querySelector('#mso').value, limit: g('#msl') || 50 };
|
||
try { const saved = await mmApi('/smart', mmOpt('POST', { id: sp && sp.id, name: el.querySelector('#msn').value, rules })); closeSheet(); toast('Saved'); location.hash = '#/music/smart/' + saved.id; if (sp) route(); } catch (x) { el.querySelector('#mse2').textContent = x.message; }
|
||
};
|
||
}
|
||
|
||
// ================================================================ Favorites, History and stats
|
||
async function musicFavorites({ v }) {
|
||
mmLoading(v, 'Favorites');
|
||
const list = await mfLoad(true), tracks = await mmTracksByKeys(list.map(x => x.key)); if (!location.hash.startsWith('#/music/favorites')) return;
|
||
mmPage(v, { title: 'Favorites', sub: tracks.length + ' songs you hearted', tracks, back: ['#/music/mixes', 'Mixes'], onChange: () => route() });
|
||
}
|
||
async function musicStats({ v, st }, range = 'month') {
|
||
mmLoading(v, 'Your listening');
|
||
let s, recent; try { [s, recent] = await Promise.all([mmApi('/stats?range=' + range), mmApi('/history?limit=30')]); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Your listening</h1><p class="err">${esc(e.message)}</p></div>`; return; }
|
||
if (!location.hash.startsWith('#/music/stats')) return;
|
||
const ranges = [['week', 'Week'], ['month', 'Month'], ['year', 'Year'], ['all', 'All time']], max = Math.max(1, ...s.series.map(x => x.minutes)), top = s.artists[0] ? s.artists[0].plays : 1;
|
||
v.innerHTML = `<div class="wrap"><h1>Your listening</h1><div class="seg">${ranges.map(([k, l]) => `<a href="#" data-r="${k}" class="${k === range ? 'on' : ''}">${l}</a>`).join('')}</div>
|
||
<div class="statgrid"><div class="stat"><b>${s.plays}</b><small>songs played</small></div><div class="stat"><b>${mmHours(s.minutes)}</b><small>listened</small></div><div class="stat"><b>${s.artistsHeard}</b><small>artists</small></div><div class="stat"><b>${s.streak}</b><small>day streak</small></div></div>
|
||
${s.plays ? `<h2>Minutes ${range === 'week' || range === 'month' ? 'per day' : 'per month'}</h2><div class="chart">${s.series.map(x => `<span title="${esc(x.d)}: ${x.minutes} min"><i style="height:${Math.max(2, Math.round(x.minutes / max * 100))}%"></i></span>`).join('')}</div>
|
||
<h2>Top artists</h2><div class="list">${s.artists.map(a => `<div class="row"><span class="grow"><b>${esc(a.name || 'Unknown')}</b><div class="statbar"><i style="width:${Math.round(a.plays / top * 100)}%"></i></div></span><small>${a.plays} plays · ${mmHours(a.minutes)}</small></div>`).join('')}</div>
|
||
<h2>Top songs</h2><div class="list">${s.songs.map((t, i) => `<button class="row" data-k="${esc(t.key)}"><span class="num">${i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span><small>${t.plays}×</small></button>`).join('')}</div>
|
||
${s.albums.length ? `<h2>Top albums</h2><div class="agrid">${s.albums.filter(a => a.key).map(a => `<a class="acard" href="#/music/album/${esc(a.key)}"><img class="art" loading="lazy" alt="" src="${px.art(a.thumb)}"><b>${esc(a.title)}</b><small>${esc(a.artist)} · ${a.plays} plays</small></a>`).join('')}</div>` : ''}` : '<p class="empty">Nothing yet. Songs you play from now on are counted here.</p>'}
|
||
<h2>Recently played</h2><div class="list">${recent.map((t, i) => `<button class="row" data-h="${i}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)} · ${esc(new Date(t.t).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }))}</small></span>${icon('play')}</button>`).join('') || '<p class="empty">Nothing played yet.</p>'}</div>
|
||
${s.plays || recent.length ? '<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="mhc">Clear my history</button></div>' : ''}</div>`;
|
||
v.querySelectorAll('[data-r]').forEach(a => a.onclick = e => { e.preventDefault(); musicStats({ v, st }, a.dataset.r); });
|
||
const play = async keys => { const tr = await mmTracksByKeys(keys); if (tr.length) playQueue(tr, 0); else toast('That song is not in your library any more'); };
|
||
v.querySelectorAll('[data-k]').forEach(b => b.onclick = () => play([b.dataset.k]));
|
||
v.querySelectorAll('[data-h]').forEach(b => b.onclick = () => play([recent[+b.dataset.h].key]));
|
||
if ($('#mhc')) $('#mhc').onclick = () => confirmSheet('Clear your listening history?', 'The stats and the mixes that use it start over.', 'Clear', async () => { try { await mmApi('/history', { method: 'DELETE' }); toast('Cleared'); musicStats({ v, st }, range); } catch (e) { toast(e.message); } });
|
||
}
|
||
|
||
// ================================================================ Family playlists
|
||
const famCard = p => `<a class="acard" href="#/music/family/${p.id}"><span class="mixart" style="background:linear-gradient(135deg,hsl(${mmHue(p.name)} 55% 42%),hsl(${mmHue(p.name) + 40} 55% 26%))"><b>${esc(p.name)}</b></span><small>${p.count} songs · ${esc(p.ownerName)}</small></a>`;
|
||
async function musicFamilyPage({ v }, id) {
|
||
mmLoading(v, 'Family playlist');
|
||
let p; try { p = await mmApi('/family/' + id); } catch (e) { v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/playlists">${icon('back')}Playlists</a></div><p class="err">${esc(e.message)}</p></div>`; 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 ? '<button class="btn ghost" id="mfr">Rename</button><button class="btn danger" id="mfd">Delete</button>' : ''}`, 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); } });
|
||
}
|
||
/** "Add to a Family playlist…" from the song menus. */
|
||
async function addToFamily(tracks) {
|
||
let list = []; try { list = await mmApi('/family'); } catch (e) { return toast(e.message); }
|
||
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key));
|
||
const make = () => textSheet('New Family playlist', 'Everyone with Music can add to it', 'Create', async n => { try { await mmApi('/family', mmOpt('POST', { name: n, tracks: meta })); toast('Family playlist created'); if (location.hash.startsWith('#/music/playlists')) route(); } catch (e) { toast(e.message); } });
|
||
actionSheet('Add to a Family playlist', meta.length + (meta.length === 1 ? ' song' : ' songs'), [{ label: 'New Family playlist…', icon: 'plus', run: make }, ...list.map(p => ({ label: p.name + ' (' + p.count + ')', icon: 'music', run: async () => { try { const r = await mmApi(`/family/${p.id}/add`, mmOpt('POST', { tracks: meta })); toast(r.added ? 'Added to ' + p.name : 'Already in ' + p.name); } catch (e) { toast(e.message); } } }))]);
|
||
}
|
||
|
||
// ================================================================ Library radio: an endless shuffle that keeps adding songs as it plays
|
||
async function mmRadioStart(sec) {
|
||
const pls = store.get('music_feat_pls', []); toast('Starting library radio…');
|
||
const first = await mmRandomTracks(sec, 30, pls); if (!first.length) return toast('No songs found to play');
|
||
playQueue(first, 0); pl.radio = { sec, pls, seen: new Set(first.map(t => t.key)), busy: false };
|
||
toast('Library radio is on. It keeps going until you stop it.');
|
||
}
|
||
/** Called as each song starts: when the queue is nearly used up, add more. */
|
||
async function mmRadioTopUp() {
|
||
const r = pl.radio; if (!r || r.busy || pl.q.length - pl.i > 4) return; r.busy = true;
|
||
try {
|
||
let more = (await mmRandomTracks(r.sec, 30, r.pls)).filter(t => !r.seen.has(t.key));
|
||
if (!more.length) { r.seen = new Set(pl.q.slice(-6).map(t => t.key)); more = (await mmRandomTracks(r.sec, 30, r.pls)).filter(t => !r.seen.has(t.key)); } // (a small set of playlists has been played through: go round again)
|
||
more.forEach(t => r.seen.add(t.key)); if (pl.radio === r) { pl.q.push(...more); if (pl.q.length > 300 && pl.i > 100) { pl.q.splice(0, pl.i - 20); pl.i = 20; } npQueueCount(); saveState(); }
|
||
} catch { /* try again at the next song */ } finally { r.busy = false; }
|
||
}
|
||
|
||
// ================================================================ The player takes its colors from the album cover
|
||
const mmColors = new Map();
|
||
async function mmCoverTheme(t) {
|
||
const root = document.documentElement; if (!t || !t.thumb) return root.style.removeProperty('--np-a');
|
||
const src = px.art(t.thumb, 100); let c = mmColors.get(src);
|
||
if (c === undefined) {
|
||
c = await new Promise(ok => { const im = new Image(); im.onerror = () => ok(null);
|
||
im.onload = () => { try { const cv = document.createElement('canvas'); cv.width = cv.height = 16; const x = cv.getContext('2d', { willReadFrequently: true }); x.drawImage(im, 0, 0, 16, 16); const d = x.getImageData(0, 0, 16, 16).data; let r = 0, g = 0, b = 0, w = 0;
|
||
for (let i = 0; i < d.length; i += 4) { const mx = Math.max(d[i], d[i + 1], d[i + 2]), mn = Math.min(d[i], d[i + 1], d[i + 2]), k = (mx - mn) + 8; r += d[i] * k; g += d[i + 1] * k; b += d[i + 2] * k; w += k; }
|
||
r /= w; g /= w; b /= w; const mx = Math.max(r, g, b) / 255, mn = Math.min(r, g, b) / 255, l = (mx + mn) / 2, dl = mx - mn; let h = 0; const s = dl === 0 ? 0 : dl / (1 - Math.abs(2 * l - 1));
|
||
if (dl) { if (mx * 255 === r) h = ((g - b) / 255 / dl) % 6; else if (mx * 255 === g) h = (b - r) / 255 / dl + 2; else h = (r - g) / 255 / dl + 4; } h = Math.round((h * 60 + 360) % 360); ok(`hsl(${h} ${Math.round(Math.max(35, Math.min(75, s * 100)))}% 45%)`); } catch { ok(null); } };
|
||
im.src = src; });
|
||
mmColors.set(src, c); if (mmColors.size > 80) mmColors.delete(mmColors.keys().next().value);
|
||
}
|
||
if (typeof cur === 'function' && cur() !== t) return;
|
||
if (c) root.style.setProperty('--np-a', c); else root.style.removeProperty('--np-a');
|
||
}
|
||
|
||
// ================================================================ Albums saved on this device (they play with no signal)
|
||
const mmOff = {
|
||
async count(tracks) { try { const c = await caches.open(OFFLINE); let n = 0; for (const t of tracks) if (await c.match(t.src)) n++; return n; } catch { return 0; } },
|
||
async save(tracks, progress) {
|
||
if (!('caches' in window)) throw new Error('This browser cannot save for offline'); const c = await caches.open(OFFLINE); let n = 0;
|
||
for (const t of tracks) { if (!(await c.match(t.src))) { const r = await fetch(t.src, { credentials: 'same-origin' }); if (!r.ok) throw new Error('Could not download "' + t.title + '" (' + r.status + ')'); await c.put(t.src, r); } progress(++n); }
|
||
if (navigator.storage && navigator.storage.persist) navigator.storage.persist().catch(() => {});
|
||
},
|
||
async remove(tracks) { try { const c = await caches.open(OFFLINE); for (const t of tracks) await c.delete(t.src); } catch { /* nothing saved */ } },
|
||
};
|
||
/** The "Save offline" button on an album: saves every song, or takes them off the device again. */
|
||
async function mmOfflineButton(btn, tracks) {
|
||
if (!btn || !tracks.length) return; let busy = false;
|
||
const paint = async () => { const n = await mmOff.count(tracks); btn.dataset.n = n; btn.textContent = n === tracks.length ? 'Saved offline ✓' : n ? `Saved ${n} of ${tracks.length}` : 'Save offline'; btn.style.color = n === tracks.length ? 'var(--accent)' : ''; };
|
||
await paint();
|
||
btn.onclick = async () => {
|
||
if (busy) return; busy = true;
|
||
try {
|
||
if (+btn.dataset.n === tracks.length) { await mmOff.remove(tracks); toast('Removed from this device'); }
|
||
else { await mmOff.save(tracks, n => { btn.textContent = `Saving ${n} of ${tracks.length}…`; }); toast('Saved on this device'); }
|
||
} catch (e) { toast(e.name === 'QuotaExceededError' ? 'There is not enough room on this device' : e.message); }
|
||
busy = false; paint();
|
||
};
|
||
}
|
||
|
||
// ================================================================ A verse for the day, under Featured
|
||
const VERSES = [['JHN', 3, 16], ['PSA', 23, 1], ['PHP', 4, 13], ['ROM', 8, 28], ['ISA', 41, 10], ['PRO', 3, 5], ['JER', 29, 11], ['MAT', 11, 28], ['PSA', 46, 1], ['ROM', 12, 2], ['JOS', 1, 9], ['2TI', 1, 7], ['PSA', 119, 105], ['HEB', 11, 1], ['1CO', 13, 4], ['EPH', 2, 8], ['PSA', 27, 1], ['MAT', 6, 33], ['GAL', 5, 22], ['1JN', 4, 19], ['PSA', 121, 1], ['ISA', 40, 31], ['LAM', 3, 22], ['PHP', 4, 6], ['COL', 3, 23], ['PSA', 34, 8], ['JAS', 1, 5], ['1PE', 5, 7], ['ZEP', 3, 17], ['PSA', 100, 1]];
|
||
async function mmVerse(el) {
|
||
if (!el || !allowed('bible') || typeof bbLoad !== 'function') return;
|
||
try {
|
||
await bbLoad(); const [b, c, v] = VERSES[Math.floor(Date.now() / 864e5) % VERSES.length], text = bbVerseText(b, c, v); if (!text || !el.isConnected) return;
|
||
el.innerHTML = `<small class="sub">Verse of the day</small><blockquote class="mmverse">${esc(text)}</blockquote><small>${esc(bbRef(b, c, v))} · <a href="#/bible" style="color:var(--accent)">Open the Bible</a></small>`;
|
||
} catch { /* no Bible text: no verse */ }
|
||
}
|