Plex playlists you can edit (create, add, remove, rename, delete, save queue), sleep timer

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 07:52:29 -04:00
1 parent fa1452d23d
commit c8399ccaa7
5 files changed
+102 -12

No files matched your search

+18 -7
View File
@@ -1153,17 +1153,22 @@ async function musicRoute([kind = 'albums', key], st) {
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 => mapTrack(t, mc)).filter(Boolean);
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></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 = () => trackSheet(tracks[+b.dataset.m]));
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();
@@ -1273,11 +1278,13 @@ function recover(now) {
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); };
const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); npQueueCount(); toast('Added to queue'); };
function trackSheet(t) {
function trackSheet(t, extra) {
actionSheet(t.title, t.artist, [
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
{ 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]) },
...(extra || []),
]);
}
// Restore the last queue and position on launch (paused, so nothing starts by itself).
@@ -1293,13 +1300,17 @@ function openQueue() {
const el = openSheet('Up next', pl.q.length + ' tracks', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="qsp" style="flex:1;justify-content:center">Save as playlist</button></div>
<div class="bar" style="margin-top:0"><button class="btn ghost" id="qsl" style="flex:1;justify-content:center">${esc(sleepLabel())} timer</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#qsp').onclick = () => { const q = pl.q.slice(); closeSheet(); addToPlaylist(q, 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' })); };
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(); });
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) {
if (auto && sleepT.eot) { sleepClear(); pl.a.pause(); syncUI(); toast('Sleep timer: music stopped'); return; }
if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; }
if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; }
loadCur(auto ? 0 : 220);
@@ -1412,11 +1423,11 @@ function renderNpCard() {
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
<button class="btn ghost npc-l" id="cnl">Lyrics</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button></div>`;
<button class="btn ghost npc-l" id="cnl">Lyrics</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
const sk = el.querySelector('#cnk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnl').onclick = lyrOpen;
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen;
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');
modeUI(); syncUI();
+1
View File
@@ -55,6 +55,7 @@
<script src="/webauthn.js"></script>
<script src="/links.js"></script>
<script src="/palette.js"></script>
<script src="/music-extras.js"></script>
<script src="/calendar-views.js"></script>
<script src="/app.js"></script>
<script src="/shell.js"></script>
+64
View File
@@ -0,0 +1,64 @@
// 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);
}