104 lines
7.8 KiB
JavaScript
104 lines
7.8 KiB
JavaScript
// Music extras: Plex playlists you can change (create, add, remove, rename, delete, save the queue) and a sleep timer.
|
|
// Uses the player globals from app.js (px, pl, jfetch, actionSheet, textSheet, confirmSheet, toast, syncUI, saveState).
|
|
|
|
// ---- Plex playlists. The hub only lets /plex/playlists... writes through, so nothing else on the server can be changed from here.
|
|
const plx = {
|
|
_m: null,
|
|
async mid() { return plx._m || (plx._m = (await px.get('/identity')).machineIdentifier); },
|
|
async uri(keys) { return `server://${await plx.mid()}/com.plexapp.plugins.library/library/metadata/${keys.join(',')}`; },
|
|
async call(method, path) {
|
|
const r = await fetch('/plex' + path, { method, headers: { Accept: 'application/json' } });
|
|
if (!r.ok) throw new Error('Plex said no (' + r.status + '). Your Plex account may not be allowed to change playlists.');
|
|
return r.json().catch(() => ({}));
|
|
},
|
|
async list() { return ((await px.get('/playlists?playlistType=audio')).Metadata || []).filter(p => !p.smart); },
|
|
async create(title, keys) { return plx.call('POST', `/playlists?type=audio&smart=0&title=${encodeURIComponent(title)}&uri=${encodeURIComponent(await plx.uri(keys))}`); },
|
|
async add(id, keys) { return plx.call('PUT', `/playlists/${encodeURIComponent(id)}/items?uri=${encodeURIComponent(await plx.uri(keys))}`); },
|
|
remove: (id, pid) => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}/items/${encodeURIComponent(pid)}`),
|
|
rename: (id, title) => plx.call('PUT', `/playlists/${encodeURIComponent(id)}?title=${encodeURIComponent(title)}`),
|
|
del: id => plx.call('DELETE', `/playlists/${encodeURIComponent(id)}`),
|
|
};
|
|
|
|
/** Pick a playlist (or make a new one) and put these tracks in it. */
|
|
async function addToPlaylist(tracks, suggest = '') {
|
|
const keys = tracks.map(t => t.key).filter(Boolean); if (!keys.length) return;
|
|
let lists = [];
|
|
try { lists = await plx.list(); } catch (e) { return toast(e.message); }
|
|
const newOne = () => textSheet('New playlist', 'Playlist name', 'Create', async name => {
|
|
try { await plx.create(name, keys); toast('Playlist “' + name + '” created'); if (location.hash.startsWith('#/music/playlists')) route(); } catch (e) { toast(e.message); }
|
|
}, suggest);
|
|
actionSheet('Add to playlist', keys.length + (keys.length === 1 ? ' track' : ' tracks'), [
|
|
{ label: 'New playlist…', icon: 'plus', run: newOne },
|
|
...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); } } })),
|
|
]);
|
|
}
|
|
|
|
// ---- Sleep timer: fades the music out and pauses after a set time, or when the current song ends.
|
|
const sleepT = { end: 0, eot: false, timer: null, fade: null };
|
|
function sleepClear() { clearTimeout(sleepT.timer); clearInterval(sleepT.fade); sleepT.end = 0; sleepT.eot = false; pl.a.volume = 1; }
|
|
function sleepLabel() {
|
|
if (sleepT.eot) return 'Sleep · end of song';
|
|
if (sleepT.end) return 'Sleep · ' + Math.max(1, Math.round((sleepT.end - Date.now()) / 6e4)) + ' min';
|
|
return 'Sleep';
|
|
}
|
|
function sleepFade() {
|
|
clearInterval(sleepT.fade);
|
|
const t0 = Date.now(), v0 = pl.a.volume || 1;
|
|
sleepT.fade = setInterval(() => {
|
|
const f = Math.min(1, (Date.now() - t0) / 8000);
|
|
pl.a.volume = Math.max(0, v0 * (1 - f));
|
|
if (f >= 1) { clearInterval(sleepT.fade); pl.a.pause(); pl.a.volume = 1; sleepT.end = 0; const b = document.getElementById('cnz'); if (b) b.textContent = 'Sleep'; toast('Sleep timer: music stopped'); }
|
|
}, 200);
|
|
}
|
|
function sleepSet(minutes) {
|
|
sleepClear();
|
|
if (minutes === 'eot') sleepT.eot = true;
|
|
else if (minutes > 0) { sleepT.end = Date.now() + minutes * 6e4; sleepT.timer = setTimeout(sleepFade, minutes * 6e4); }
|
|
const b = document.getElementById('cnz'); if (b) b.textContent = sleepLabel();
|
|
toast(minutes ? (minutes === 'eot' ? 'Music will stop when this song ends' : 'Music will fade out in ' + minutes + ' minutes') : 'Sleep timer off');
|
|
}
|
|
function sleepSheet() {
|
|
const opts = [[15, '15 minutes'], [30, '30 minutes'], [45, '45 minutes'], [60, '1 hour'], [90, '1.5 hours'], ['eot', 'When this song ends']].map(([v, label]) => ({ label, icon: 'pause', run: () => sleepSet(v) }));
|
|
if (sleepT.end || sleepT.eot) opts.push({ label: 'Turn off the timer', icon: 'x', run: () => sleepSet(0) });
|
|
actionSheet('Sleep timer', sleepLabel() === 'Sleep' ? 'Fades the music out and pauses' : sleepLabel(), opts);
|
|
}
|
|
|
|
// ---- Plex Home: use Music as someone else in your Plex Home (their PIN if they have one)
|
|
const plexHome = { t: 0, data: null };
|
|
async function plexHomeUsers() {
|
|
if (plexHome.data && Date.now() - plexHome.t < 5 * 60e3) return plexHome.data;
|
|
plexHome.data = await jfetch('/api/plex/users'); plexHome.t = Date.now(); return plexHome.data;
|
|
}
|
|
/** After the Music header is drawn: "Playing as Nate" with a switch link, but only when the Plex Home has more than one person. */
|
|
async function plexHomeBadge(st) {
|
|
try {
|
|
const d = await plexHomeUsers(), el = document.getElementById('phu');
|
|
if (!el || !d.users || d.users.length < 2) return;
|
|
const me = d.users.find(x => x.current) || d.users.find(x => x.admin) || d.users[0];
|
|
el.innerHTML = ` · <a href="#" id="pswitch" style="color:var(--accent)">Playing as ${esc(me.title)} ▾</a>`;
|
|
document.getElementById('pswitch').onclick = e => { e.preventDefault(); plexSwitchSheet(d.users); };
|
|
} catch { /* no Plex Home: nothing to switch */ }
|
|
}
|
|
function plexSwitchSheet(users) {
|
|
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
|
|
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
|
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
|
|
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
|
el.querySelector('#sx').onclick = closeSheet;
|
|
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected) plexPinSheet(u); else plexSwitchTo(u, ''); });
|
|
}
|
|
function plexPinSheet(u) {
|
|
const el = openSheet('PIN for ' + u.title, '', `<form id="ppf"><label for="ppi">PIN</label><input type="password" id="ppi" inputmode="numeric" autocomplete="off" maxlength="8" required>
|
|
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="ppb" style="flex:1;justify-content:center">Switch</button></div><div class="err" id="ppe" role="alert"></div></form>`);
|
|
el.querySelector('#sx').onclick = closeSheet;
|
|
el.querySelector('#ppf').onsubmit = e => { e.preventDefault(); plexSwitchTo(u, el.querySelector('#ppi').value, msg => { el.querySelector('#ppe').textContent = msg; el.querySelector('#ppi').select(); }); };
|
|
setTimeout(() => el.querySelector('#ppi').focus(), 60);
|
|
}
|
|
async function plexSwitchTo(u, pin, onError) {
|
|
try {
|
|
await jfetch('/api/plex/switch', { method: 'POST', body: JSON.stringify({ id: u.id, pin }) });
|
|
closeSheet(); plexHome.data = null; px.secs = null; wl.id = ''; try { for (const k of Object.keys(localStorage)) if (k.startsWith('vc:')) localStorage.removeItem(k); } catch {} // (what the last person had on their home screen)
|
|
toast('Now using Plex as ' + u.title); route();
|
|
} catch (e) { if (onError) onError(e.message); else toast(e.message); }
|
|
}
|