163 lines
12 KiB
JavaScript
163 lines
12 KiB
JavaScript
'use strict';
|
|
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
|
|
const jfetch = async (path, opts) => {
|
|
const r = await fetch(path, opts); const j = await r.json().catch(() => ({}));
|
|
if (!r.ok) throw Object.assign(new Error(j.error || 'Request failed (' + r.status + ')'), { status: r.status });
|
|
return j;
|
|
};
|
|
const px = {
|
|
secs: null,
|
|
async get(path) {
|
|
const r = await fetch('/plex' + path, { headers: { Accept: 'application/json' } });
|
|
if (r.status === 401) throw Object.assign(new Error('Plex session expired'), { auth: true });
|
|
if (!r.ok) throw new Error('Plex error ' + r.status);
|
|
return (await r.json()).MediaContainer || {};
|
|
},
|
|
art: (thumb, w = 300) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
|
|
// All music libraries on the server; the chosen one is remembered per server.
|
|
async sections() {
|
|
if (px.secs) return px.secs;
|
|
const d = (await px.get('/library/sections')).Directory || [];
|
|
px.secs = d.filter(x => x.type === 'artist').map(x => ({ key: x.key, title: x.title }));
|
|
if (!px.secs.length) { px.secs = null; throw new Error('No music library found on this Plex server'); }
|
|
return px.secs;
|
|
},
|
|
async section() {
|
|
const secs = await px.sections(), want = store.get('plex_lib', {})[px.serverKey];
|
|
return (secs.find(x => x.key === want) || secs[0]).key;
|
|
},
|
|
serverKey: '',
|
|
};
|
|
const fmtDur = ms => { const s = Math.round((ms || 0) / 1000); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
|
|
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 };
|
|
};
|
|
|
|
async function viewMusic(args) {
|
|
const v = $('#view');
|
|
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Loading…</p></div>';
|
|
try {
|
|
const st = await jfetch('/api/plex/status');
|
|
if (!location.hash.startsWith('#/music')) return;
|
|
if (!st.signedIn) return plexSignIn();
|
|
if (!st.server) return plexPickServer();
|
|
await musicRoute(args, st);
|
|
renderNpCard();
|
|
} catch (e) {
|
|
if (e.auth) return plexSignIn();
|
|
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rs">Change server</button></div></div>`;
|
|
$('#rs').onclick = plexPickServer;
|
|
}
|
|
}
|
|
|
|
function plexSignIn(msg = '') {
|
|
$('#view').innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Play your Plex music library here.</p>
|
|
<div class="card"><p style="margin-top:0">You'll sign in on plex.tv in a new tab. This app never sees your Plex password.</p>
|
|
<div class="bar"><button class="btn" id="pl">Sign in with Plex</button></div><div class="err" id="pe">${esc(msg)}</div></div></div>`;
|
|
$('#pl').onclick = async () => {
|
|
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
|
|
try {
|
|
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
|
|
if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `<a href="${esc(pin.authUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">Tap to open Plex sign-in</a>`;
|
|
for (let i = 0; i < 150; i++) {
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
if (!location.hash.startsWith('#/music')) return;
|
|
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
|
|
if (r.ok) { try { w?.close(); } catch {} return route(); }
|
|
}
|
|
$('#pe').textContent = 'Timed out waiting for Plex. Try again.';
|
|
} catch (e) { $('#pe').textContent = e.message; }
|
|
};
|
|
}
|
|
|
|
async function plexPickServer() {
|
|
const v = $('#view');
|
|
v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="empty">Finding your Plex servers…</p></div>';
|
|
try {
|
|
const servers = await jfetch('/api/plex/servers');
|
|
if (!servers.length) { v.innerHTML = '<div class="wrap"><h1>Music</h1><p class="err">No Plex servers found on this account.</p></div>'; return; }
|
|
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Choose a server</p><div class="list">${servers.map(s => `
|
|
<button class="row" data-id="${esc(s.id)}"><span class="ic" style="background:#e5a00d">${icon('music')}</span>
|
|
<span class="grow"><b>${esc(s.name)}</b><small>${s.owned ? 'Yours' : 'Shared with you'}</small></span>${icon('chev', 'chev')}</button>`).join('')}</div><div class="err" id="pe"></div><div id="pm"></div></div>`;
|
|
v.querySelectorAll('button.row').forEach(b => b.onclick = async () => {
|
|
b.disabled = true; $('#pe').textContent = '';
|
|
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id }) }); px.secs = null; route(); }
|
|
catch (e) {
|
|
b.disabled = false; $('#pe').textContent = e.message;
|
|
// Offer a hand-typed address (e.g. the server's LAN address) when automatic discovery fails.
|
|
$('#pm').innerHTML = `<p class="sub">Know the server's address? Enter it (for example <b>http://192.168.1.10:32400</b>).</p>
|
|
<form id="pf2"><input type="url" id="pu" placeholder="http://192.168.1.10:32400" autocomplete="off" autocapitalize="off" required>
|
|
<div class="bar"><button class="btn">Try this address</button></div></form>`;
|
|
$('#pf2').onsubmit = async ev => {
|
|
ev.preventDefault(); $('#pe').textContent = '';
|
|
try { await jfetch('/api/plex/server', { method: 'POST', body: JSON.stringify({ id: b.dataset.id, uri: $('#pu').value.trim() }) }); px.secs = null; route(); }
|
|
catch (e2) { $('#pe').textContent = e2.message; }
|
|
};
|
|
}
|
|
});
|
|
} catch (e) { if (e.status === 401) return plexSignIn(); v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p></div>`; }
|
|
}
|
|
|
|
async function musicRoute([kind = 'albums', key], st) {
|
|
px.serverKey = st.server;
|
|
const v = $('#view'), sec = await px.section(), secs = await px.sections(), cur = secs.find(x => x.key === sec) || secs[0];
|
|
const tabs = ['albums', 'artists', 'playlists', 'recent'];
|
|
const seg = `<div class="seg">${tabs.map(t => `<a href="#/music/${t}" class="${t === kind ? 'on' : ''}">${t[0].toUpperCase() + t.slice(1)}</a>`).join('')}</div>`;
|
|
const card = (href, thumb, title, sub, round) => `<a class="acard ${round ? 'round' : ''}" href="${href}" data-q="${esc((title + ' ' + sub).toLowerCase())}">
|
|
<img class="art" loading="lazy" alt="" src="${px.art(thumb)}"><b>${esc(title)}</b><small>${esc(sub)}</small></a>`;
|
|
const listing = (items, html) => {
|
|
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">${esc(st.server)}${secs.length > 1 ? ` · <a href="#" id="plib" style="color:var(--accent)">${esc(cur.title)} ▾</a>` : ''}</p>${seg}<input type="search" id="mq" placeholder="Filter" autocomplete="off">
|
|
<div class="agrid" style="margin-top:14px">${html || '<p class="empty">Nothing here</p>'}</div></div>`;
|
|
const pl2 = $('#plib');
|
|
if (pl2) pl2.onclick = e => { e.preventDefault(); actionSheet('Music library', st.server, secs.map(x => ({ label: x.title + (x.key === cur.key ? ' ✓' : ''), icon: 'music',
|
|
run: () => { const m = store.get('plex_lib', {}); m[st.server] = x.key; store.set('plex_lib', m); location.hash = '#/music/albums'; route(); } }))); };
|
|
$('#mq').oninput = e => { const q = e.target.value.toLowerCase(); v.querySelectorAll('.acard').forEach(c => c.style.display = c.dataset.q.includes(q) ? '' : 'none'); };
|
|
};
|
|
const big = '&X-Plex-Container-Start=0&X-Plex-Container-Size=3000&sort=titleSort';
|
|
|
|
if (kind === 'albums') { const mc = await px.get(`/library/sections/${sec}/all?type=9${big}`);
|
|
return listing(0, (mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.parentTitle || '')).join('')); }
|
|
if (kind === 'recent') { const mc = await px.get(`/library/sections/${sec}/recentlyAdded?type=9&X-Plex-Container-Start=0&X-Plex-Container-Size=60`);
|
|
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 === 'artist') {
|
|
const mc = await px.get(`/library/metadata/${key}/children`);
|
|
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/artists">${icon('back')}Artists</a></div><h1>${esc(mc.title2 || mc.parentTitle || 'Artist')}</h1>
|
|
<h2>Albums</h2><div class="agrid">${(mc.Metadata || []).map(a => card('#/music/album/' + a.ratingKey, a.thumb, a.title, a.year || '')).join('')}</div></div>`;
|
|
return;
|
|
}
|
|
if (kind === 'album' || kind === 'playlist') {
|
|
const album = kind === 'album';
|
|
const [mc, meta] = await Promise.all([px.get(album ? `/library/metadata/${key}/children` : `/playlists/${key}/items`),
|
|
px.get(album ? `/library/metadata/${key}` : `/playlists/${key}`)]);
|
|
const info = (meta.Metadata || [])[0] || {};
|
|
const tracks = (mc.Metadata || []).map(t => { const m = mapTrack(t, mc); if (m && !album) m.pid = t.playlistItemID; return m; }).filter(Boolean);
|
|
const mine = !album && !info.smart;
|
|
const title = info.title || mc.title2 || '', sub = album ? (info.parentTitle || mc.title1 || '') : tracks.length + ' tracks';
|
|
const thumb = info.thumb || info.composite || mc.thumb;
|
|
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
|
|
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
|
|
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
|
|
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
|
|
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
|
|
<button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
|
|
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
|
|
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { 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) {
|
|
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
|
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
|
|
}
|
|
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
|
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
|
markPlaying();
|
|
return;
|
|
}
|
|
location.hash = '#/music/albums';
|
|
}
|