diff --git a/README.md b/README.md index eb15b5b..ba7d2e7 100644 --- a/README.md +++ b/README.md @@ -170,3 +170,11 @@ Files are kept in the data folder under `mystore/` (up to 1 GB each; extensions **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. +**Music, more** (Plex music; what is about a person is kept by the hub in `music-*.json` in their folder, and `music-family.json` for Family playlists): +- **Mixes tab:** a Daily mix (what you play, your favorites and a few surprises), Most played this month, Forgotten favorites, Moods (calm, upbeat, focus, worship, built from your genres), On this day (albums released today in past years), Decades, Genres (each with a featured album and a shuffle), *Library radio* (an endless shuffle that keeps adding songs; it uses the playlists chosen on Featured), and **Smart playlists**: your own rules (genres, years, added recently, not played in a while, played at least N times, favorites only, order, size) that pick songs each time you open them. +- **Favorites:** a heart on songs (in lists, the full player and the song menu); the Favorites page plays them. Kept by the hub, so they follow you between devices. +- **Stats tab:** songs played, time listened, artists, day streak, minutes per day or month, top artists, songs and albums, and recently played; for a week, month, year or all time. A song counts when it has been played for half its length. *Clear my history* starts over. +- **Family playlists** (Playlists tab, and *Add to playlist* on any song): shared by everyone who has Music; anyone can add or remove songs, only the owner or an admin renames or deletes one. +- **Also:** reorder the queue with the arrows; *More like this* on an artist or song; an About note on albums and artists (Plex's own summary); **Save offline** on an album (plays with no signal); the player takes its colors from the album cover; **Share a line** in the lyrics makes a picture of the lyric over the cover; Featured shows a verse of the day when you have the Bible tab. +- Not included: crossfade or gapless (the player already keeps the next songs downloaded for quick changes), searching lyrics by a line, and a speaker picker. + diff --git a/musicdata.js b/musicdata.js new file mode 100644 index 0000000..928bef1 --- /dev/null +++ b/musicdata.js @@ -0,0 +1,107 @@ +// Music data kept by the hub (Plex only lets the hub read the library, so what is about a person lives here): what they played (for history, stats and mixes), their +// favorites, their smart playlists (rules that pick songs from the Plex library), and Family playlists that everyone with Music can add to. Songs are remembered +// by their Plex key plus enough to show them; the player fetches the song itself from Plex when it is played. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const { readBody } = require('./body'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'), FAMILY = () => path.join(DATA_DIR, 'music-family.json'); +const mine = n => path.join(users.dir(), n); +const read = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } }; +const write = (f, v) => { fs.mkdirSync(path.dirname(f), { recursive: true }); atomicWrite(f, JSON.stringify(v), { mode: 0o600 }); }; +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); +const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').trim().slice(0, n); +const keyOk = k => /^\d{1,12}$/.test(String(k || '')); +const newId = () => crypto.randomBytes(6).toString('hex'); +const DAY = 864e5, MAX_PLAYS = 20000, MAX_FAVS = 5000, MAX_FAMILY_TRACKS = 2000; + +/** A song as it is kept: its Plex key and what is needed to list it. */ +function song(b) { + if (!b || !keyOk(b.key)) throw fail(400, 'That is not a song from your music library'); + return { key: String(b.key), title: str(b.title, 200) || 'Song', artist: str(b.artist, 200), album: str(b.album, 200), thumb: str(b.thumb, 300), artistKey: keyOk(b.artistKey) ? String(b.artistKey) : '', albumKey: keyOk(b.albumKey) ? String(b.albumKey) : '', secs: Math.max(0, Math.min(36000, Math.round(+b.secs || (+b.dur || 0) / 1000 || 0))) }; +} + +// ---- what was played +const plays = () => read(mine('music-history.json'), []); +function recordPlay(b) { + const s = song(b), l = plays(), last = l[l.length - 1], now = Date.now(); + if (last && last.key === s.key && now - last.t < 20000) return { ok: true, duplicate: true }; // (the same song counted twice by a reload) + l.push({ ...s, t: now }); write(mine('music-history.json'), l.slice(-MAX_PLAYS)); return { ok: true }; +} +const since = (l, days) => days ? l.filter(p => p.t >= Date.now() - days * DAY) : l; +const top = (l, kf, mk, n) => { const m = new Map(); for (const p of l) { const k = kf(p); if (!k) continue; const e = m.get(k) || { ...mk(p), plays: 0, secs: 0 }; e.plays++; e.secs += p.secs || 0; m.set(k, e); } return [...m.values()].sort((a, b) => b.plays - a.plays || b.secs - a.secs).slice(0, n).map(e => ({ ...e, minutes: Math.round(e.secs / 60) })); }; +const dayKey = t => { const d = new Date(t); return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); }; +/** Summary numbers for a stretch of time: totals, top artists, songs and albums, minutes per day (or per month for a long stretch), and the listening streak. */ +function stats(range) { + const days = { week: 7, month: 30, year: 365, all: 0 }[range] ?? 30, all = plays(), l = since(all, days); + const perDay = new Map(); for (const p of l) perDay.set(dayKey(p.t), (perDay.get(dayKey(p.t)) || 0) + (p.secs || 0) / 60); + const series = []; if (days && days <= 31) for (let i = days - 1; i >= 0; i--) { const k = dayKey(Date.now() - i * DAY); series.push({ d: k, minutes: Math.round(perDay.get(k) || 0) }); } + else { const m = new Map(); for (const [k, v] of perDay) m.set(k.slice(0, 7), (m.get(k.slice(0, 7)) || 0) + v); const keys = [...m.keys()].sort(); for (const k of (days ? keys.slice(-12) : keys.slice(-24))) series.push({ d: k, minutes: Math.round(m.get(k)) }); } + const played = new Set(all.map(p => dayKey(p.t))); let streak = 0; for (let i = played.has(dayKey(Date.now())) ? 0 : 1; played.has(dayKey(Date.now() - i * DAY)); i++) streak++; + return { range, plays: l.length, minutes: Math.round(l.reduce((n, p) => n + (p.secs || 0), 0) / 60), artistsHeard: new Set(l.map(p => p.artist).filter(Boolean)).size, streak, + artists: top(l, p => p.artist, p => ({ name: p.artist, key: p.artistKey, thumb: p.thumb }), 10), songs: top(l, p => p.key, p => ({ key: p.key, title: p.title, artist: p.artist, thumb: p.thumb }), 10), + albums: top(l, p => p.albumKey || p.album, p => ({ key: p.albumKey, title: p.album, artist: p.artist, thumb: p.thumb }), 8), series }; +} + +// ---- favorites +const favs = () => read(mine('music-favs.json'), []); +const saveFavs = l => write(mine('music-favs.json'), l.slice(-MAX_FAVS)); + +// ---- smart playlists: rules the player turns into a question for the Plex library +const GENRE_OK = /^[\w .&'/+-]{1,60}$/; +function rules(r = {}) { + const n = (v, lo, hi) => { v = Math.round(+v); return Number.isFinite(v) && v >= lo && v <= hi ? v : 0; }; + return { genres: (Array.isArray(r.genres) ? r.genres : []).map(String).filter(g => GENRE_OK.test(g)).slice(0, 10), yearFrom: n(r.yearFrom, 1000, 2100), yearTo: n(r.yearTo, 1000, 2100), addedDays: n(r.addedDays, 1, 3650), notPlayedDays: n(r.notPlayedDays, 1, 3650), minPlays: n(r.minPlays, 1, 1000), + onlyFavorites: !!r.onlyFavorites, order: ['random', 'newest', 'mostPlayed', 'leastRecent'].includes(r.order) ? r.order : 'random', limit: n(r.limit, 5, 500) || 50 }; +} +const smart = () => read(mine('music-smart.json'), []); + +// ---- Family playlists +const family = () => read(FAMILY(), []); +const saveFamily = l => write(FAMILY(), l); +const view = p => ({ id: p.id, name: p.name, owner: p.owner, ownerName: p.ownerName, created: p.created, count: p.tracks.length, tracks: p.tracks }); +const canChange = (p, me) => me && (me.role === 'admin' || p.owner === me.id); + +async function handle(req, res) { + const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/music'.length) || '/', me = users.current(), q = u.searchParams; + try { + let m; + if (p === '/played' && req.method === 'POST') return json(res, 200, recordPlay(JSON.parse(await readBody(req, 2e4) || '{}'))); + if (p === '/history' && req.method === 'GET') { const n = Math.min(200, Math.max(1, +q.get('limit') || 40)); return json(res, 200, plays().slice(-n).reverse()); } + if (p === '/stats' && req.method === 'GET') return json(res, 200, stats(q.get('range') || 'month')); + if (p === '/top' && req.method === 'GET') { const days = Math.min(3650, Math.max(0, +q.get('days') || 30)); return json(res, 200, top(since(plays(), days), x => x.key, x => ({ key: x.key, title: x.title, artist: x.artist }), Math.min(200, +q.get('limit') || 50))); } + if (p === '/history' && req.method === 'DELETE') { write(mine('music-history.json'), []); return json(res, 200, { ok: true }); } + // favorites + if (p === '/favs' && req.method === 'GET') return json(res, 200, favs().reverse()); + if (p === '/favs' && req.method === 'POST') { const s = song(JSON.parse(await readBody(req, 2e4) || '{}')), l = favs().filter(x => x.key !== s.key); l.push({ ...s, t: Date.now() }); saveFavs(l); return json(res, 200, { ok: true }); } + if ((m = /^\/favs\/(\d{1,12})$/.exec(p)) && req.method === 'DELETE') { saveFavs(favs().filter(x => x.key !== m[1])); return json(res, 200, { ok: true }); } + // smart playlists + if (p === '/smart' && req.method === 'GET') return json(res, 200, smart()); + if (p === '/smart' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 1e4) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name'); + const l = smart(), old = b.id ? l.find(x => x.id === b.id) : null; if (b.id && !old) throw fail(404, 'Playlist not found'); if (!old && l.length >= 50) throw fail(400, 'That is plenty of smart playlists'); + const e = old || { id: newId() }; Object.assign(e, { name, rules: rules(b.rules) }); if (!old) l.push(e); write(mine('music-smart.json'), l); return json(res, 200, e); + } + if ((m = /^\/smart\/([0-9a-f]{12})$/.exec(p)) && req.method === 'DELETE') { write(mine('music-smart.json'), smart().filter(x => x.id !== m[1])); return json(res, 200, { ok: true }); } + // family playlists + if (p === '/family' && req.method === 'GET') return json(res, 200, family().map(view)); + if (p === '/family' && req.method === 'POST') { + const b = JSON.parse(await readBody(req, 2e5) || '{}'), name = str(b.name, 60); if (!name) throw fail(400, 'Give the playlist a name'); const l = family(); if (l.length >= 100) throw fail(400, 'That is plenty of family playlists'); + const e = { id: newId(), name, owner: me.id, ownerName: me.name, created: Date.now(), tracks: (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })) }; l.push(e); saveFamily(l); return json(res, 200, view(e)); + } + if ((m = /^\/family\/([0-9a-f]{12})(?:\/(add|tracks\/(\d{1,12})))?$/.exec(p))) { + const l = family(), e = l.find(x => x.id === m[1]); if (!e) throw fail(404, 'Playlist not found'); + if (!m[2] && req.method === 'GET') return json(res, 200, view(e)); + if (!m[2] && req.method === 'POST') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can rename it'); const name = str(JSON.parse(await readBody(req, 1e4) || '{}').name, 60); if (!name) throw fail(400, 'Give the playlist a name'); e.name = name; saveFamily(l); return json(res, 200, view(e)); } + if (!m[2] && req.method === 'DELETE') { if (!canChange(e, me)) throw fail(403, 'Only the person who made it, or an admin, can delete it'); saveFamily(l.filter(x => x !== e)); return json(res, 200, { ok: true }); } + if (m[2] === 'add' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 2e5) || '{}'), add = (Array.isArray(b.tracks) ? b.tracks : []).slice(0, 500).map(t => ({ ...song(t), by: me.name })); let n = 0; + for (const t of add) if (!e.tracks.some(x => x.key === t.key) && e.tracks.length < MAX_FAMILY_TRACKS) { e.tracks.push(t); n++; } saveFamily(l); return json(res, 200, { ...view(e), added: n }); } + if (m[3] && req.method === 'DELETE') { e.tracks = e.tracks.filter(x => x.key !== m[3]); saveFamily(l); return json(res, 200, view(e)); } // (anyone in the family may take a song off) + } + return json(res, 404, { error: 'Not found' }); + } catch (e) { return json(res, e.code >= 400 && e.code < 600 ? e.code : e.status === 413 ? 413 : 400, { error: e instanceof SyntaxError ? 'Bad request' : e.message }); } +} + +module.exports = { handle, stats, rules, song }; diff --git a/package.json b/package.json index 072d304..76a2662 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "main": "server.js", "scripts": { "start": "node server.js", - "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav; do node tools/$t-test.js || exit 1; done", + "test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata; do node tools/$t-test.js || exit 1; done", "audit": "npm audit --omit=dev --audit-level=high" }, "keywords": [], diff --git a/public/app.js b/public/app.js index 1949290..e6df3c1 100644 --- a/public/app.js +++ b/public/app.js @@ -95,6 +95,8 @@ const ICONS = { cal: '', store: '', download: '', + arrup: '', + arrdn: '', chef: '', people: '', search: '', diff --git a/public/index.html b/public/index.html index 35513c9..2aab080 100644 --- a/public/index.html +++ b/public/index.html @@ -59,6 +59,7 @@ + diff --git a/public/lyrics-view.js b/public/lyrics-view.js index 9d7e18f..2f4442f 100644 --- a/public/lyrics-view.js +++ b/public/lyrics-view.js @@ -99,16 +99,16 @@ function lyrShell() {

Looking for the lyrics…

-
+
`; const $l = q => lyr.el.querySelector(q); - if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech) + if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech) $l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle; $l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; }; const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); }; $l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4); - $l('#lyrs').onclick = lyrSearchAgain; + $l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare; const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; }; $l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur())); syncUI(); @@ -172,3 +172,33 @@ pl.a.addEventListener('playing', async () => { if (r.state === 'none' && r.canTranscribe) await jfetch('/api/transcript?' + q, { method: 'POST' }); } catch { lyrAsked.delete(t.key); } }); + +// ---- Share a line: the lyric over the album cover, as a picture (shared through the phone's share sheet, or downloaded) +function lyrShare() { + const t = cur(), d = lyr.data; if (!t || !d || !d.found || d.instrumental || !d.lines) return toast('There are no lyrics to share yet'); + const text = l => l.words ? l.words.map(w => w.text).join(' ') : l.text, rows = d.lines.map((l, i) => ({ i, s: (text(l) || '').trim() })).filter(x => x.s).slice(0, 250); + const now = d.sync !== 'none' && lyr.idx >= 0 ? lyr.idx : -1, el = openSheet('Share a line', 'Tick up to four lines for the picture.', `
${rows.map(x => ``).join('')}
+
`); + el.querySelector('#shx').onclick = closeSheet; + el.querySelectorAll('input[data-l]').forEach(c => c.onchange = () => { if (el.querySelectorAll('input[data-l]:checked').length > 4) c.checked = false; }); + el.querySelector('#shgo').onclick = async () => { + const picked = [...el.querySelectorAll('input[data-l]:checked')].map(c => rows.find(x => x.i === +c.dataset.l).s); if (!picked.length) return toast('Tick at least one line'); + closeSheet(); + try { const blob = await lyrCard(t, picked), file = new File([blob], (t.title || 'lyric').replace(/[^\w -]+/g, '') + '.png', { type: 'image/png' }); + if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: t.title, text: t.title + ' — ' + t.artist }).catch(e => { if (e.name !== 'AbortError') throw e; }); + else { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast('Saved the picture'); } } catch (e) { toast(e.message || 'Could not make the picture'); } + }; +} +async function lyrCard(t, lines) { + const W = 1080, H = 1350, cv = document.createElement('canvas'); cv.width = W; cv.height = H; const x = cv.getContext('2d'); + const img = await new Promise(ok => { const im = new Image(); im.onload = () => ok(im); im.onerror = () => ok(null); im.src = px.art(t.thumb, 800); }); + x.fillStyle = '#14161b'; x.fillRect(0, 0, W, H); + if (img) { x.save(); x.filter = 'blur(40px) saturate(1.3)'; const k = Math.max(W / img.width, H / img.height) * 1.2; x.drawImage(img, (W - img.width * k) / 2, (H - img.height * k) / 2, img.width * k, img.height * k); x.restore(); } + const g = x.createLinearGradient(0, 0, 0, H); g.addColorStop(0, 'rgba(0,0,0,.35)'); g.addColorStop(1, 'rgba(0,0,0,.75)'); x.fillStyle = g; x.fillRect(0, 0, W, H); + const text = lines.join('\n'), size = text.length > 160 ? 52 : text.length > 90 ? 62 : 74; x.fillStyle = '#fff'; x.font = `700 ${size}px system-ui, -apple-system, Segoe UI, sans-serif`; x.textAlign = 'center'; x.textBaseline = 'middle'; + const wrapped = []; for (const ln of lines) { let cur1 = ''; for (const w of ln.split(/\s+/)) { const tryl = cur1 ? cur1 + ' ' + w : w; if (x.measureText(tryl).width > W - 160 && cur1) { wrapped.push(cur1); cur1 = w; } else cur1 = tryl; } wrapped.push(cur1); } + const lh = size * 1.3, y0 = H / 2 - ((wrapped.length - 1) * lh) / 2 - 40; wrapped.forEach((ln, i) => x.fillText(ln, W / 2, y0 + i * lh)); + if (img) { const s = 150, ax = 100, ay = H - 250; x.save(); x.beginPath(); x.roundRect ? x.roundRect(ax, ay, s, s, 18) : x.rect(ax, ay, s, s); x.clip(); x.drawImage(img, ax, ay, s, s); x.restore(); } + x.textAlign = 'left'; x.font = '700 40px system-ui, sans-serif'; x.fillText(t.title.slice(0, 34), 280, H - 200); x.font = '400 34px system-ui, sans-serif'; x.fillStyle = 'rgba(255,255,255,.75)'; x.fillText((t.artist || '').slice(0, 40), 280, H - 150); + return new Promise((ok, no) => cv.toBlob(b => b ? ok(b) : no(new Error('Could not make the picture')), 'image/png')); +} diff --git a/public/music-extras.js b/public/music-extras.js index 3680672..ca18f65 100644 --- a/public/music-extras.js +++ b/public/music-extras.js @@ -29,6 +29,7 @@ async function addToPlaylist(tracks, suggest = '') { }, suggest); actionSheet('Add to playlist', keys.length + (keys.length === 1 ? ' track' : ' tracks'), [ { label: 'New playlist…', icon: 'plus', run: newOne }, + ...(typeof addToFamily === 'function' ? [{ label: 'A Family playlist…', icon: 'music', run: () => addToFamily(tracks) }] : []), ...lists.map(p => ({ label: p.title, icon: 'music', run: async () => { try { await plx.add(p.ratingKey, keys); toast('Added to ' + p.title); if (location.hash === '#/music/playlist/' + p.ratingKey) route(); } catch (e) { toast(e.message); } } })), ]); } diff --git a/public/music-more.js b/public/music-more.js new file mode 100644 index 0000000..4ad7184 --- /dev/null +++ b/public/music-more.js @@ -0,0 +1,319 @@ +'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 ? `` : ''; + 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 = `

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

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

${esc(g.title)}

${albums.length} albums

+
+ ${feat ? `

Featured album

` : ''} +

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

${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 = `

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

${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 */ } +} diff --git a/public/music.js b/public/music.js index db17e83..06b282b 100644 --- a/public/music.js +++ b/public/music.js @@ -32,7 +32,7 @@ const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor const mapTrack = (t, mc) => { const part = t.Media?.[0]?.Part?.[0]; return part && { key: t.ratingKey, title: t.title, artist: t.grandparentTitle || t.originalTitle || mc.title1 || '', - album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0 }; + album: t.parentTitle || mc.title2 || '', thumb: t.thumb || t.parentThumb || mc.thumb, src: '/plex' + part.key, dur: t.duration || 0, artistKey: t.grandparentRatingKey, albumKey: t.parentRatingKey }; }; async function viewMusic(args) { @@ -110,11 +110,11 @@ async function plexPickServer() { } catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `

Music

${esc(e.message)}

`; } } -async function musicRoute([kind = 'featured', key], st) { +async function musicRoute([kind = 'featured', key, key2], 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 = ['featured', 'albums', 'artists', 'playlists', 'recent']; - const seg = `
${tabs.map(t => `${t[0].toUpperCase() + t.slice(1)}`).join('')}
`; + const tabs = ['featured', 'mixes', 'albums', 'artists', 'playlists', 'favorites', 'stats', 'recent'], TAB_NAME = { recent: 'New', stats: 'Stats' }; + const seg = ``; const card = (href, thumb, title, sub, round) => ` ${esc(title)}${esc(sub)}`; const listing = (items, html) => { @@ -128,7 +128,16 @@ async function musicRoute([kind = 'featured', key], st) { }; const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort'; + const ctx = { sec, card, listing, st, v }; if (kind === 'featured') return musicFeatured({ sec, card, listing, st }); + if (kind === 'mixes') return musicMixes(ctx); + if (kind === 'mix') return musicMixPage(ctx, key, key2, arguments[0][3]); + if (kind === 'genre') return musicGenrePage(ctx, key); + if (kind === 'decade') return musicDecadePage(ctx, key); + if (kind === 'smart') return musicSmartPage(ctx, key); + if (kind === 'family') return musicFamilyPage(ctx, key); + if (kind === 'favorites') return musicFavorites(ctx); + if (kind === 'stats') return musicStats(ctx); 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('')); } @@ -136,12 +145,17 @@ async function musicRoute([kind = 'featured', key], st) { return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); } if (kind === 'artists') { const mc = await px.get(`/library/sections/${sec}/all?type=8${big}`); return listing(0, (mc.Metadata || []).map(a => card('#/music/artist/' + a.ratingKey, a.thumb, a.title, '', true)).join('')); } - if (kind === 'playlists') { const mc = await px.get('/playlists?playlistType=audio'); - return listing(0, (mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('')); } + if (kind === 'playlists') { const [mc, fam] = await Promise.all([px.get('/playlists?playlistType=audio'), mmApi('/family').catch(() => [])]); + listing('raw', `

Your playlists

${(mc.Metadata || []).map(a => card('#/music/playlist/' + a.ratingKey, a.composite, a.title, (a.leafCount || 0) + ' tracks')).join('') || '

No playlists yet.

'}
+

Family playlists

Shared with everyone who has Music. Anyone can add and remove songs.

${fam.map(famCard).join('')}
+
`); + $('#fnew').onclick = () => textSheet('New Family playlist', 'Everyone with Music can add to it', 'Create', async n => { try { const r = await mmApi('/family', mmOpt('POST', { name: n })); location.hash = '#/music/family/' + r.id; } catch (e) { toast(e.message); } }); + return; } if (kind === 'artist') { - const mc = await px.get(`/library/metadata/${key}/children`); + const [mc, am] = await Promise.all([px.get(`/library/metadata/${key}/children`), px.get(`/library/metadata/${key}`).catch(() => ({}))]), about = ((am.Metadata || [])[0] || {}).summary || ''; v.innerHTML = `

${esc(mc.title2 || mc.parentTitle || 'Artist')}

+ ${about ? `
About

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

` : ''}

Albums

${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}
`; return; } @@ -156,10 +170,12 @@ async function musicRoute([kind = 'featured', key], st) { const thumb = info.thumb || info.composite || mc.thumb; v.innerHTML = `

${esc(title)}

${esc(sub)}

-
${mine ? '' : ''}
+ ${info.summary ? `
About

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

` : ''} +
${album ? '' : ''}${mine ? '' : ''}
${tracks.map((t, i) => `
-
`).join('') || '

No playable tracks

'}
`; +
`).join('') || '

No playable tracks

'}`; + v.querySelectorAll('button[data-fi]').forEach(b => b.onclick = () => mfToggle(tracks[+b.dataset.fi])); mfLoad().then(mfPaint); if (album) mmOfflineButton($('#pof'), tracks); v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p)); v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); }); if (mine) { @@ -206,8 +222,8 @@ async function musicFeatured({ sec, card, listing, st }) { const big = (kind, o, round) => `
Featured ${kind}

${esc(o.title)}

${o.artist ? `

${esc(o.artist)}

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

${esc(err)}

` : ''}
${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}
`); - const v = $('#view'); + ${err ? `

${esc(err)}

` : ''}
${p.artist ? big('artist', p.artist, true) : ''}${p.album ? big('album', p.album) : ''}
`); + const v = $('#view'); mmVerse(document.getElementById('mverse')); v.querySelectorAll('[data-fp]').forEach(b => b.onclick = async () => { b.disabled = true; try { diff --git a/public/player.js b/public/player.js index 0ab19b2..5544ce8 100644 --- a/public/player.js +++ b/public/player.js @@ -3,7 +3,7 @@ const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; }; const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() }; const cur = () => pl.q[pl.i]; -function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); } +function playQueue(q, i = 0, shuffleOn = false) { pl.radio = null; pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); } // Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order. function setShuffle(on) { if (on === pl.shuffle || !pl.q.length) return; @@ -63,6 +63,7 @@ function loadCur(delay = 220) { setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState(); if (lyr.open) { lyrShell(); lyrReload(); } if (typeof vizTrackChanged === 'function') vizTrackChanged(); + if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint(); const go = () => { if (tok !== loadTok) return; const hit = tc.m.get(t.src); @@ -107,6 +108,11 @@ function trackSheet(t, extra) { { label: 'Add to queue', icon: 'files', run: () => queueTrack(t) }, { label: 'Play now', icon: 'play', run: () => playQueue([t], 0) }, { label: 'Add to playlist…', icon: 'plus', run: () => addToPlaylist([t]) }, + ...(typeof mmPlex === 'function' && mmPlex(t.key) ? [ + { label: mfIs(t) ? 'Remove from Favorites' : 'Add to Favorites', icon: 'heart', run: () => mfToggle(t) }, + { label: 'Add to a Family playlist…', icon: 'plus', run: () => addToFamily([t]) }, + ...(t.artistKey ? [{ label: 'More like this', icon: 'shuffle', run: () => { location.hash = '#/music/mix/similar/' + t.artistKey; } }] : []), + ] : []), ...(extra || []), ]); } @@ -120,9 +126,9 @@ function restorePlayer() { } function openQueue() { const upcoming = pl.q.map((t, i) => ({ t, i })); - const el = openSheet('Up next', pl.q.length + ' tracks', `
${upcoming.map(({ t, i }) => ` + const el = openSheet('Up next', pl.q.length + ' tracks' + (pl.radio ? ' · library radio is on' : ''), `
${upcoming.map(({ t, i }) => `
- ${i === pl.i ? '' : ``}
`).join('')}
+ ${i > 0 ? `` : ''}${i < pl.q.length - 1 ? `` : ''}${i === pl.i ? '' : ``}
`).join('')}
`); el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet; @@ -130,6 +136,8 @@ function openQueue() { el.querySelector('#qsl').onclick = () => { closeSheet(); sleepSheet(); }; el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); }; el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); }); + const move = (k, d) => { const j = k + d; if (j < 0 || j >= pl.q.length) return; [pl.q[k], pl.q[j]] = [pl.q[j], pl.q[k]]; if (pl.i === k) pl.i = j; else if (pl.i === j) pl.i = k; pl.orig = null; saveState(); tcKick(); updateNow(); openQueue(); }; + el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => move(+b.dataset.u, -1)); el.querySelectorAll('[data-dn]').forEach(b => b.onclick = () => move(+b.dataset.dn, 1)); el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); }); } function advance(auto) { @@ -159,7 +167,7 @@ pl.a.addEventListener('timeupdate', () => { if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); } const t = cur(), d = pl.a.duration; if (t && String(t.key).startsWith('pod:')) { if (!pl.scrobbled && d && pl.a.currentTime > d * 0.9) { pl.scrobbled = true; fetch('/api/podcast/podcastitems/' + encodeURIComponent(t.key.slice(4)) + '/markPlayed').catch(() => {}); } } // a podcast episode counts as heard near its end - else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex + else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); if (typeof musicPlayed === 'function') musicPlayed(t, d); } // counts as played in Plex if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} } }); addEventListener('pagehide', saveState); @@ -205,7 +213,7 @@ function renderMini() { let miniSwiped = false; function stopPlayer() { tlStop(); - pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {} + pl.radio = null; pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {} if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; } tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose(); } @@ -248,7 +256,7 @@ function renderNpCard() { el.innerHTML = `
NOW PLAYING${esc(t.title)}${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
0:000:00
-
+
`; const sk = el.querySelector('#cnk'); @@ -256,7 +264,7 @@ function renderNpCard() { sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; }; el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen; el.querySelector('#cnv').onclick = vizOpen; el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle; - el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off'); + el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off'); modeUI(); syncUI(); } // The Now Playing sheet stays open while tracks change, so refresh what it shows. @@ -274,12 +282,13 @@ function openNow() {
0:000:00
-
${castOk(pl.a) ? '' : ''}
`); +
${castOk(pl.a) ? '' : ''}
`); const sk = el.querySelector('#npk'); sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); }; sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; }; el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a); el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle); + el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint); el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off'); el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle; updateNow(); syncUI(); diff --git a/public/style.css b/public/style.css index e2efa3e..2a3eb0c 100644 --- a/public/style.css +++ b/public/style.css @@ -984,3 +984,17 @@ body.vizopen { overflow: hidden; } .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; } } + +/* Music: mixes, stats, notes, cover colors */ +.npcard, .np { background: linear-gradient(160deg, color-mix(in srgb, var(--np-a, transparent) 30%, var(--panel)), var(--panel)); transition: background .6s; } +.mixtile .mixart, .mixart { display: flex; align-items: flex-end; width: 100%; aspect-ratio: 1 / 1; border-radius: 12px; padding: 12px; box-sizing: border-box; color: #fff; } +.mixart b { font-size: 18px; line-height: 1.15; text-shadow: 0 1px 6px rgba(0,0,0,.4); } +.mhead .mixart { width: 120px; height: 120px; flex: none; aspect-ratio: auto; } +.mmabout { margin: 8px 0 12px; } .mmabout summary { cursor: pointer; color: var(--accent); } .mmabout p { margin: 8px 0 0; line-height: 1.5; white-space: normal; max-width: 70ch; } +.mmv { margin-top: 18px; padding: 14px 16px; border-radius: 14px; background: var(--panel); border: 1px solid var(--line); } .mmv:empty { display: none; } +.mmverse { margin: 6px 0; font-size: 18px; line-height: 1.5; font-style: italic; } +.statgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin: 8px 0 4px; } @media (min-width: 640px) { .statgrid { grid-template-columns: repeat(4, 1fr); } } +.stat { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px; } .stat b { display: block; font-size: 26px; } .stat small { color: var(--muted); } +.chart { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding: 6px 0; } .chart span { flex: 1; height: 100%; display: flex; align-items: flex-end; } .chart i { display: block; width: 100%; background: var(--accent); border-radius: 3px 3px 0 0; min-height: 2px; } +.statbar { height: 6px; border-radius: 3px; background: var(--panel2); margin-top: 6px; overflow: hidden; } .statbar i { display: block; height: 100%; background: var(--accent); } +[data-fv][aria-pressed="true"] svg, #npfav.on svg, #cnfav.on svg { fill: currentColor; } diff --git a/public/sw.js b/public/sw.js index cdf327b..e144578 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-v56'; +const CACHE = 'hub-v57'; 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']; @@ -46,6 +46,7 @@ self.addEventListener('fetch', e => { const u = new URL(e.request.url); if (e.request.method === 'POST' && u.origin === location.origin && u.pathname === '/share-target') return e.respondWith(takeShare(e.request)); if (e.request.method === 'GET' && u.origin === location.origin && /^\/api\/podcast\/podcasts\/[^/]+\/file$/.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); + if (e.request.method === 'GET' && u.origin === location.origin && /^\/plex\/library\/parts\//.test(u.pathname)) return e.respondWith(offlineAudio(e.request)); // (albums saved on this device) if (e.request.method !== 'GET' || u.origin !== location.origin || u.pathname.startsWith('/sf/') || u.pathname.startsWith('/plex/') || u.pathname.startsWith('/api/')) return; e.respondWith(fetch(e.request).then(r => { if (r.ok) { const copy = r.clone(); caches.open(CACHE).then(c => c.put(e.request, copy)); } diff --git a/server.js b/server.js index 8688977..449c142 100644 --- a/server.js +++ b/server.js @@ -43,6 +43,7 @@ const savefiles = require('./savefiles'); // "Save to Files": copy books, lesson const vbv = require('./vbv'); // Verse By Verse Ministry teaching (free Bible studies) const recipes = require('./recipes'); // recipes and the shopping list, from Mealie (MEALIE_URL, MEALIE_TOKEN) const contacts = require('./contacts'); // each person's address book (mail suggestions, calendar birthdays, vCard import and export) +const musicdata = require('./musicdata'); // what was played, favorites, smart and Family playlists for Music const mystore = require('./mystore'); // My Store: your own books, music, apps and extensions for everyone to use const radio = require('./radio'); // internet radio (Radio Browser directory, The Journey FM) const librivox = require('./librivox'); // free audiobooks from LibriVox @@ -554,6 +555,7 @@ const ACCESS = [ [/^\/api\/bible\//, ['bible']], [/^\/api\/read\//, ['read']], [/^\/api\/radio\//, ['radio']], + [/^\/api\/music(\/|$)/, ['music']], [/^\/api\/store\/my(\/|$)/, ['store']], [/^\/api\/recipes(\/|$)/, ['recipes']], [/^\/api\/contacts(\/|$)/, ['contacts']], @@ -652,6 +654,7 @@ async function handleRequest(req, res) { if (pathname.startsWith('/api/podcast/')) return podcast.handle(req, res); if (pathname.startsWith('/api/read/')) return books.handle(req, res); if (pathname.startsWith('/api/radio/')) return radio.handle(req, res); + if (pathname.startsWith('/api/music/')) return musicdata.handle(req, res); if (pathname === '/api/store/my' || pathname.startsWith('/api/store/my/')) return mystore.handle(req, res); if (pathname === '/api/recipes' || pathname.startsWith('/api/recipes/')) return recipes.handle(req, res); if (pathname === '/api/contacts' || pathname.startsWith('/api/contacts/')) return contacts.handle(req, res); diff --git a/tools/musicdata-test.js b/tools/musicdata-test.js new file mode 100644 index 0000000..c23445b --- /dev/null +++ b/tools/musicdata-test.js @@ -0,0 +1,37 @@ +// Music data: plays, history and stats, favorites, smart playlist rules, and Family playlists shared by everyone with Music. +const http = require('http'), assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-md-')); +const users = require('../users'), md = require('../musicdata'); +const people = { a: users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), b: users.create({ name: 'Bob', role: 'user', tabs: ['music'], handle: 'Yg' }), c: users.create({ name: 'Cy', role: 'user', tabs: ['music'], handle: 'Yw' }) }; +const hub = http.createServer((req, res) => users.runAs(people[req.headers['x-user'] || 'a'], () => md.handle(req, res))).listen(0, async () => { + const B = 'http://127.0.0.1:' + hub.address().port + '/api/music', call = (p, o = {}, who) => fetch(B + p, { ...o, headers: { ...(o.headers || {}), ...(who ? { 'x-user': who } : {}) } }), post = (p, b, who, m = 'POST') => call(p, { method: m, body: JSON.stringify(b) }, who), j = async r => r.json(); + const s = (n, o = {}) => ({ key: String(100 + n), title: 'Song ' + n, artist: 'Artist ' + (n % 2), album: 'Album ' + n, thumb: '/library/metadata/1/thumb/1', artistKey: String(10 + n % 2), albumKey: String(50 + n), dur: 180000, ...o }); + try { + // plays + assert.equal((await post('/played', { key: 'abc', title: 'x' })).status, 400, 'only Plex songs'); + for (const n of [1, 2, 1, 3, 1]) assert.equal((await post('/played', s(n))).status, 200); + const h = await j(await call('/history?limit=3')); assert.equal(h.length, 3); assert.equal(h[0].title, 'Song 1', 'newest first'); + assert.equal((await j(await post('/played', s(1)))).duplicate, true, 'the same song twice in a moment counts once'); assert.equal((await j(await call('/history'))).length, 5); + const st = await j(await call('/stats?range=week')); + assert.ok(st.plays >= 4 && st.minutes >= 9); assert.equal(st.songs[0].title, 'Song 1'); assert.equal(st.songs[0].plays, 3, 'the most played song is first'); assert.equal(st.artists[0].name, 'Artist 1'); assert.equal(st.series.length, 7, 'a week has seven days'); assert.equal(st.series.at(-1).minutes, st.minutes, 'all of it was today'); assert.equal(st.streak, 1); + assert.equal((await j(await call('/stats?range=year'))).series.length, 1, 'a year is shown per month'); assert.deepEqual((await j(await call('/stats', {}, 'b'))).plays, 0, "each person's own history"); + assert.equal((await j(await call('/top?days=30&limit=2'))).length, 2); + // favorites + await post('/favs', s(7)); await post('/favs', s(8)); await post('/favs', s(7)); let f = await j(await call('/favs')); assert.equal(f.length, 2, 'saving twice keeps one'); assert.equal(f[0].key, '107', 'the one just saved again is newest'); + await call('/favs/107', { method: 'DELETE' }); assert.deepEqual((await j(await call('/favs'))).map(x => x.key), ['108']); assert.deepEqual(await j(await call('/favs', {}, 'b')), []); + // smart playlist rules are cleaned + let sp = await j(await post('/smart', { name: 'Old gospel', rules: { genres: ['Gospel', '