Movies & TV: Watch later (a Plex playlist), Collections, who-is-watching switch

This commit is contained in:
nmemmert committed 2026-10-23 09:32:00 -04:00
1 parent 92cf86bf5c
commit 93ae8f8840
2 files changed
+27 -6

No files matched your search

+1 -1
View File
@@ -97,7 +97,7 @@ function plexPinSheet(u) {
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;
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); }
}
+26 -5
View File
@@ -53,6 +53,19 @@ const vdCard = (m, wide, deck) => {
<b>${esc(m.type === 'episode' && wide ? m.title : m.title)}</b><small>${m.type === 'episode' ? vdEp(m) + (vdLeft(m) ? ' · ' + vdLeft(m) + ' min left' : '') : esc([m.year, vdLeft(m) ? vdLeft(m) + ' min left' : '', m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}</small></a>`;
return deck ? `<div class="vdeckc">${html}<button class="vx" data-rm="${m.ratingKey}" aria-label="Remove from Continue watching" title="Remove from Continue watching">${icon('x')}</button></div>` : html;
};
// ---- Watch later: a normal Plex video playlist called "Watch later" (so it follows the Plex person you use, and shows in Plex's own apps too)
const wl = { id: '' };
async function wlItems() {
if (!wl.id) { const p = ((await px.get('/playlists?playlistType=video')).Metadata || []).find(x => x.title === 'Watch later' && !x.smart); if (!p) return []; wl.id = p.ratingKey; }
return (await px.get(`/playlists/${wl.id}/items`)).Metadata || [];
}
async function wlAdd(rk) {
const uri = await plx.uri([rk]);
if (!wl.id) { await wlItems(); }
if (wl.id) return plx.call('PUT', `/playlists/${wl.id}/items?uri=${encodeURIComponent(uri)}`);
const r = await plx.call('POST', `/playlists?type=video&smart=0&title=${encodeURIComponent('Watch later')}&uri=${encodeURIComponent(uri)}`); wl.id = (((r.MediaContainer || {}).Metadata || [])[0] || {}).ratingKey || '';
}
async function wlRemove(rk) { const it = (await wlItems()).find(x => x.ratingKey === String(rk)); if (it) await plx.call('DELETE', `/playlists/${wl.id}/items/${it.playlistItemID}`); }
/** Take an item off Continue watching. Newer Plex servers can just hide it; an older one cannot, so it is marked watched instead (and we say so). */
async function vdRemove(m, quiet) {
try { const r = await fetch('/plex/actions/removeFromContinueWatching?ratingKey=' + encodeURIComponent(m.ratingKey), { method: 'PUT' }); if (!r.ok) throw new Error(r.status); if (!quiet) toast('Removed from Continue watching'); }
@@ -66,10 +79,11 @@ async function videoHome(st, libKey) {
if (!secs.length) { v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">This Plex server has no movie or TV libraries (or your Plex user cannot see them).</p></div>'; return; }
const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0];
store.set('video_lib', cur.key);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
<input type="search" id="vq" placeholder="Search movies and shows" autocomplete="off" aria-label="Search"><div id="vsr"></div>
<div id="vhero"></div><div id="vdeck"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } };
@@ -89,6 +103,8 @@ async function videoHome(st, libKey) {
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
}).catch(() => {});
}
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
// sort and filter the library below
const fs = store.get('video_sort', 'titleSort:asc'), fu = store.get('video_unw', '0') === '1'; let genre = '';
$('#vsort').value = fs; $('#vunwc').checked = fu;
@@ -147,13 +163,18 @@ async function videoItem(id) {
<div class="vinfo"><h1 style="margin:0">${esc(m.type === 'episode' ? m.title : m.title)}</h1>${m.type === 'episode' ? `<p class="sub" style="margin:2px 0">${vdEp(m)}</p>` : m.type === 'season' ? `<p class="sub" style="margin:2px 0">${esc(m.parentTitle || '')}</p>` : ''}<p class="sub" style="margin:4px 0">${esc(meta)}</p>
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
${m.type !== 'season' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
${m.type !== 'season' && m.type !== 'collection' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
<p class="vsum">${esc(m.summary || '')}</p>
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
<div id="vkids"></div></div>`;
jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then
if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
}).catch(() => {});
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
$('#vwl').onclick = async () => { try { on ? await wlRemove(id) : await wlAdd(id); toast(on ? 'Removed from Watch later' : 'Added to Watch later'); route(); } catch (e) { toast(e.message); } };
}).catch(() => {});
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
@@ -167,10 +188,10 @@ async function videoItem(id) {
const e = (mc.Metadata || [])[0]; if (!e || !$('#vact')) return;
$('#vact').insertAdjacentHTML('afterbegin', `<button class="btn" id="vfirst">${icon('play')}Play S${e.parentIndex}E${e.index}</button>`); $('#vfirst').onclick = () => { location.hash = `#/video/watch/${e.ratingKey}`; };
}).catch(() => {});
if (m.type === 'show' || m.type === 'season') {
if (m.type === 'show' || m.type === 'season' || m.type === 'collection') {
const kids = (await px.get(`/library/metadata/${id}/children`)).Metadata || [];
if (!$('#vkids')) return;
$('#vkids').innerHTML = m.type === 'show' ? `<h2>Seasons</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
$('#vkids').innerHTML = m.type === 'show' || m.type === 'collection' ? `<h2>${m.type === 'show' ? 'Seasons' : 'In this collection'}</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
: `<h2>Episodes</h2><div class="list">${kids.map(e => `<a class="row vep" href="#/video/item/${e.ratingKey}"><span class="vimg" style="aspect-ratio:16/9;width:120px;flex:none">${e.thumb ? `<img loading="lazy" alt="" src="${vdArt(e.thumb, 240, 135)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${e.viewCount > 0 && !e.viewOffset ? `<em class="vseen">${icon('check')}</em>` : ''}${vdPct(e) ? `<i class="vprog"><b style="width:${vdPct(e)}%"></b></i>` : ''}</span><span class="grow"><b>${e.index}. ${esc(e.title)}</b><small style="white-space:normal">${esc(vdTime(e.duration))}${e.summary ? ' · ' + esc(e.summary.slice(0, 110)) + (e.summary.length > 110 ? '…' : '') : ''}</small></span></a>`).join('')}</div>`;
}
}