'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 ? `
${icon('back')}${esc(back[1])}
` : ''; v.innerHTML = `
${hrefBack}
${thumb ? `` : `
`}

${esc(title)}

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

${about ? `
About

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

` : ''}
${extra}
${tracks.map((t, i) => `
`).join('') || '

No playable songs

'}
`; 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 = `

${esc(title)}

Putting it together…

`; }; // ================================================================ 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) => `${esc(title)}${esc(sub)}`; 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) => `${esc(a.title)}${esc(sub)}`; listing('raw', `
An endless shuffle of your music. It uses the playlists chosen on Featured, if you chose any.

Mixes for you

${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')}
${moods.length ? `

Moods

${moods.map(m => tile('#/music/mix/mood/' + m[0], m[1], 'A mix of ' + m[1].toLowerCase() + ' music')).join('')}
` : ''} ${today.length ? `

On this day

Albums released on ${new Date().toLocaleDateString([], { month: 'long', day: 'numeric' })} in past years

${today.map(a => card(a, (a.originallyAvailableAt || '').slice(0, 4) + ' · ' + (a.parentTitle || ''))).join('')}
` : ''}

Decades

${decades.map(d => `${d}s`).join('')}
${genres.length ? `

Genres

${genres.slice(0, 40).map(g => `${esc(g.title)}`).join('')}
` : ''}

Smart playlists

Rules that pick songs for you (for example: gospel from the 80s you have not played in a year).

${smart.map(s => tile('#/music/smart/' + s.id, s.name, 'Smart playlist')).join('')}
`); $('#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: `` }); v.querySelector('#mpr').onclick = () => musicMixPage({ sec, v }, id, arg); } catch (e) { v.innerHTML = `
${icon('back')}Mixes

Mix

${esc(e.message)}

`; } } 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 = `
${icon('back')}Mixes

That genre is not in your library.

`; 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 = `
${icon('back')}Mixes

${esc(g.title)}

${albums.length} albums

${feat ? `

Featured album

${esc(feat.title)}${esc(feat.parentTitle || '')}
` : ''}

Albums

${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')}
`; $('#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 = `
${icon('back')}Mixes

${start}s

${albums.length} albums

${albums.map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, (a.year || '') + ' · ' + (a.parentTitle || ''))).join('') || '

Nothing from this decade.

'}
`; $('#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 = `
${icon('back')}Mixes

That smart playlist is gone.

`; 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: `` }); 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) => ``; const el = openSheet(sp ? 'Edit smart playlist' : 'New smart playlist', 'Songs are picked by these rules each time you open it.', `
${genres.length ? `
${genres.slice(0, 40).map(g => `${esc(g.title)}`).join('')}
` : ''}
${num('msy1', 'From year', r.yearFrom, '1980')}
${num('msy2', 'To year', r.yearTo, '1989')}
${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)} ${num('msl', 'Number of songs', r.limit, '50')}
`); 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 = `

Your listening

${esc(e.message)}

`; 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 = `

Your listening

${ranges.map(([k, l]) => `${l}`).join('')}
${s.plays}songs played
${mmHours(s.minutes)}listened
${s.artistsHeard}artists
${s.streak}day streak
${s.plays ? `

Minutes ${range === 'week' || range === 'month' ? 'per day' : 'per month'}

${s.series.map(x => ``).join('')}

Top artists

${s.artists.map(a => `
${esc(a.name || 'Unknown')}
${a.plays} plays · ${mmHours(a.minutes)}
`).join('')}

Top songs

${s.songs.map((t, i) => ``).join('')}
${s.albums.length ? `

Top albums

${s.albums.filter(a => a.key).map(a => `${esc(a.title)}${esc(a.artist)} · ${a.plays} plays`).join('')}
` : ''}` : '

Nothing yet. Songs you play from now on are counted here.

'}

Recently played

${recent.map((t, i) => ``).join('') || '

Nothing played yet.

'}
${s.plays || recent.length ? '
' : ''}
`; 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 => `${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 = `
${icon('back')}Playlists

${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: () => {} }); 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 = `Verse of the day
${esc(text)}
${esc(bbRef(b, c, v))} · Open the Bible`; } catch { /* no Bible text: no verse */ } }