Movies & TV: remove or clear Continue watching, time left, Recently added and Unwatched rows kept on device, sort/genre/unwatched filters, hold-for-actions, Skip intro/credits

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-23 09:21:03 -04:00
1 parent bbbb8ef5d5
commit c49693bac6
4 files changed
+80 -19

No files matched your search

+9
View File
@@ -750,3 +750,12 @@ body.nav-mini .ngrp span { display: none; }
/* Home never scrolls sideways: wide content shrinks or wraps instead */ /* Home never scrolls sideways: wide content shrinks or wraps instead */
.home, .home .hcol, .home .homeapps { min-width: 0; max-width: 100%; } .home, .home .hcol, .home .homeapps { min-width: 0; max-width: 100%; }
#view { overflow-x: hidden; } #view { overflow-x: hidden; }
/* Movies & TV: remove from Continue watching, filters */
.vdeckc { position: relative; flex: none; }
.vx { position: absolute; top: 6px; left: 6px; width: 28px; height: 28px; min-height: 0; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, .65); color: #fff; display: grid; place-items: center; cursor: pointer; opacity: .85; }
.vx svg { width: 14px; height: 14px; } .vx:hover, .vx:focus-visible { opacity: 1; background: rgba(0, 0, 0, .85); }
.vfilt { flex-wrap: wrap; align-items: center; gap: 8px; margin: 4px 0 10px; } .vfilt select { min-height: 40px; max-width: 48vw; }
.vunw { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 14px; } .vunw input { width: 18px; height: 18px; }
.vmark { position: absolute; right: 16px; bottom: 96px; z-index: 6; background: rgba(0, 0, 0, .7); color: #fff; border: 1px solid rgba(255, 255, 255, .5); }
.vdeckc .vcard.wide { width: 250px; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network. // App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v16'; const CACHE = 'hub-v17';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder 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']; const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+66 -16
View File
@@ -43,14 +43,23 @@ async function viewVideo(args = []) {
} }
// ---- Home: Continue Watching, then a library at a time // ---- Home: Continue Watching, then a library at a time
const vdCard = (m, wide) => { const vdLeft = m => m.viewOffset && m.duration ? Math.max(1, Math.round((m.duration - m.viewOffset) / 60000)) : 0;
const vdCard = (m, wide, deck) => {
const href = m.type === 'episode' && !wide ? `#/video/item/${m.ratingKey}` : `#/video/item/${m.ratingKey}`; const href = m.type === 'episode' && !wide ? `#/video/item/${m.ratingKey}` : `#/video/item/${m.ratingKey}`;
const pct = vdPct(m), seen = m.viewCount > 0 && !m.viewOffset; const pct = vdPct(m), seen = m.viewCount > 0 && !m.viewOffset;
const thumb = wide ? (m.type === 'episode' ? m.thumb : m.art || m.thumb) : (m.parentThumb && m.type === 'season' ? m.thumb || m.parentThumb : m.thumb); const thumb = wide ? (m.type === 'episode' ? m.thumb : m.art || m.thumb) : (m.parentThumb && m.type === 'season' ? m.thumb || m.parentThumb : m.thumb);
return `<a class="vcard${wide ? ' wide' : ''}" href="${href}" data-q="${esc((m.title + ' ' + (m.grandparentTitle || '')).toLowerCase())}"> const html = `<a class="vcard${wide ? ' wide' : ''}" data-rk="${m.ratingKey}" data-type="${m.type}" data-show="${m.grandparentRatingKey || ''}" data-seen="${seen ? 1 : 0}" href="${href}" data-q="${esc((m.title + ' ' + (m.grandparentTitle || '')).toLowerCase())}">
<span class="vimg" style="aspect-ratio:${wide ? '16/9' : '2/3'}">${thumb ? `<img loading="lazy" alt="" src="${vdArt(thumb, wide ? 400 : 300, wide ? 225 : 450)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${seen ? `<em class="vseen" title="Watched">${icon('check')}</em>` : ''}${pct ? `<i class="vprog"><b style="width:${pct}%"></b></i>` : ''}</span> <span class="vimg" style="aspect-ratio:${wide ? '16/9' : '2/3'}">${thumb ? `<img loading="lazy" alt="" src="${vdArt(thumb, wide ? 400 : 300, wide ? 225 : 450)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${seen ? `<em class="vseen" title="Watched">${icon('check')}</em>` : ''}${pct ? `<i class="vprog"><b style="width:${pct}%"></b></i>` : ''}</span>
<b>${esc(m.type === 'episode' && wide ? m.title : m.title)}</b><small>${m.type === 'episode' ? vdEp(m) : esc([m.year, m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}</small></a>`; <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;
}; };
/** 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'); }
catch { try { await px.get(`/:/scrobble?key=${m.ratingKey}&identifier=com.plexapp.plugins.library`); if (!quiet) toast('Marked watched (this Plex server cannot just hide it)'); } catch (e) { toast(e.message); } }
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
}
async function videoHome(st, libKey) { async function videoHome(st, libKey) {
const v = $('#view'); const v = $('#view');
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show'); const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
@@ -59,22 +68,53 @@ async function videoHome(st, libKey) {
store.set('video_lib', cur.key); 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>${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> <input type="search" id="vq" placeholder="Search movies and shows" autocomplete="off" aria-label="Search"><div id="vsr"></div>
<div id="vdeck"></div><h2 id="vlt">${esc(cur.title)}</h2><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`; <div id="vdeck"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
// Continue watching and the library load side by side <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>`;
px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=12').then(mc => { // 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 items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); if (!items.length || !$('#vdeck')) return; const vcK = k => 'vc:' + (ME ? ME.id : '') + ':' + k;
$('#vdeck').innerHTML = `<h2>Continue watching</h2><div class="vrow">${items.map(m => vdCard(m, true)).join('')}</div>`; const vcGet = k => { try { return JSON.parse(localStorage.getItem(vcK(k))); } catch { return null; } };
const vcPut = (k, items) => { try { localStorage.setItem(vcK(k), JSON.stringify(items.map(m => ({ ratingKey: m.ratingKey, type: m.type, title: m.title, year: m.year, thumb: m.thumb, art: m.art, parentThumb: m.parentThumb, grandparentTitle: m.grandparentTitle, grandparentRatingKey: m.grandparentRatingKey, parentIndex: m.parentIndex, index: m.index, viewOffset: m.viewOffset, viewCount: m.viewCount, duration: m.duration, leafCount: m.leafCount, viewedLeafCount: m.viewedLeafCount })))); } catch {} };
const rowOf = (id, title, items, deck) => { const el = $(id); if (!el) return; el.innerHTML = items.length ? `<h2>${title}${deck ? '<button class="btn ghost vclear" id="vclr" style="min-height:32px;margin-left:10px">Clear all</button>' : ''}</h2><div class="vrow">${items.map(m => vdCard(m, true, deck)).join('')}</div>` : ''; };
const drawDeck = items => { vd.deck = items; rowOf('#vdeck', 'Continue watching', items, true); if ($('#vclr')) $('#vclr').onclick = () => confirmSheet('Clear Continue watching?', 'Takes everything off this row. Your library and watch history stay as they are.', 'Clear all', async () => { for (const m of vd.deck.slice()) await vdRemove(m, true); loadDeck(); }); };
const loadDeck = () => px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=16').then(mc => {
const items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); vcPut('deck', items); if ($('#vdeck')) drawDeck(items);
}).catch(() => {}); }).catch(() => {});
const type = cur.type === 'movie' ? 1 : 2, size = 120; let start = 0, total = 0, all = []; const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
const type = cur.type === 'movie' ? 1 : 2, size = 120;
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
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(() => {});
}
// 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;
px.get(`/library/sections/${cur.key}/genre`).then(mc => { const g = $('#vgen'); if (g) g.insertAdjacentHTML('beforeend', (mc.Directory || []).map(d => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
let start = 0, total = 0, all = [], gen = 0;
const load = async () => { const load = async () => {
const mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=titleSort&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`); const my = gen, mc = await px.get(`/library/sections/${cur.key}/all?type=${type}&sort=${vdEnc($('#vsort').value)}${$('#vunwc').checked ? '&unwatched=1' : ''}${genre ? '&genre=' + vdEnc(genre) : ''}&X-Plex-Container-Start=${start}&X-Plex-Container-Size=${size}`);
if (!$('#vgrid')) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items); if (!$('#vgrid') || my !== gen) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
$('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .empty')?.remove(); $('#vgrid').insertAdjacentHTML('beforeend', items.map(m => vdCard(m)).join('')); if (start === items.length) $('#vgrid .empty')?.remove();
$('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : ''; $('#vmore').innerHTML = start < total ? `<div class="bar" style="justify-content:center"><button class="btn ghost" id="vmb">Show more (${total - start} left)</button></div>` : '';
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message)); if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
if (!all.length) $('#vgrid').innerHTML = '<p class="empty">Nothing in this library.</p>'; if (!all.length) $('#vgrid').innerHTML = '<p class="empty">Nothing matches.</p>';
}; };
$('#vgrid').innerHTML = ''; load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); const reload = () => { gen++; start = 0; total = 0; all = []; $('#vgrid').innerHTML = '<p class="empty">Loading…</p>'; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = ''; load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
$('#vsort').onchange = () => { store.set('video_sort', $('#vsort').value); reload(); }; $('#vunwc').onchange = () => { store.set('video_unw', $('#vunwc').checked ? '1' : '0'); reload(); }; $('#vgen').onchange = () => { genre = $('#vgen').value; reload(); };
reload();
// press and hold (or right-click) a card: quick actions
const vv = $('#view .wrap') || $('#view'); let hold = 0, held = false; // (the page's own wrapper, new on every draw, so the handlers never pile up)
const menu = card => {
const rk = card.dataset.rk, seen = card.dataset.seen === '1', acts = [{ label: seen ? 'Mark unwatched' : 'Mark watched', icon: 'check', run: async () => { try { await px.get(`/:/${seen ? 'unscrobble' : 'scrobble'}?key=${rk}&identifier=com.plexapp.plugins.library`); toast(seen ? 'Marked unwatched' : 'Marked watched'); route(); } catch (e) { toast(e.message); } } }];
if (card.dataset.show) acts.unshift({ label: 'Go to the show', icon: 'film', run: () => { location.hash = '#/video/item/' + card.dataset.show; } });
if (card.closest('.vdeckc')) acts.push({ label: 'Remove from Continue watching', icon: 'x', run: async () => { await vdRemove((vd.deck || []).find(m => m.ratingKey === rk) || { ratingKey: rk, type: card.dataset.type }); loadDeck(); } });
actionSheet(card.querySelector('b').textContent, '', acts);
};
vv.addEventListener('contextmenu', e => { const c = e.target.closest('.vcard'); if (c && c.dataset.rk) { e.preventDefault(); menu(c); } });
vv.addEventListener('touchstart', e => { const c = e.target.closest('.vcard'); held = false; clearTimeout(hold); if (c && c.dataset.rk) hold = setTimeout(() => { held = true; menu(c); }, 550); }, { passive: true });
for (const ev of ['touchend', 'touchmove', 'touchcancel']) vv.addEventListener(ev, () => clearTimeout(hold), { passive: true });
vv.addEventListener('click', e => { if (held) { held = false; e.preventDefault(); e.stopPropagation(); return; } const x = e.target.closest('.vx'); if (x) { e.preventDefault(); const rk = x.dataset.rm; vdRemove((vd.deck || []).find(m => m.ratingKey === rk) || { ratingKey: rk, type: 'movie' }).then(loadDeck); } }, true);
let t; $('#vq').oninput = e => { let t; $('#vq').oninput = e => {
clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; } clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; }
t = setTimeout(async () => { t = setTimeout(async () => {
@@ -144,7 +184,7 @@ const loadHls = () => hlsLib || (hlsLib = new Promise((ok, no) => { if (window.H
let vdHooked = false; let vdHooked = false;
async function videoWatch(id) { async function videoWatch(id) {
if (vp && vp.id === id && vp.el) { videoMount(); return; } // already playing this one (in the mini player): bring it back if (vp && vp.id === id && vp.el) { videoMount(); return; } // already playing this one (in the mini player): bring it back
const v = $('#view'), mc = await px.get(`/library/metadata/${id}`), m = (mc.Metadata || [])[0]; const v = $('#view'), mc = await px.get(`/library/metadata/${id}?includeMarkers=1`), m = (mc.Metadata || [])[0];
if (!m || !(m.Media || []).length) { v.innerHTML = '<div class="wrap"><p class="empty">That item has no playable file.</p><div class="bar"><a class="btn ghost" href="#/video">Back</a></div></div>'; return; } if (!m || !(m.Media || []).length) { v.innerHTML = '<div class="wrap"><p class="empty">That item has no playable file.</p><div class="bar"><a class="btn ghost" href="#/video">Back</a></div></div>'; return; }
videoStop(); videoStop();
try { pl.a.pause(); } catch {} try { pl.a.pause(); } catch {}
@@ -155,6 +195,7 @@ async function videoWatch(id) {
el.volume = Math.max(0, Math.min(1, +store.get('video_vol', 1))); el.volume = Math.max(0, Math.min(1, +store.get('video_vol', 1)));
vp = { id, m, media, part, streams, el, base: 0, hls: null, mode: '', q: store.get('video_q', 'auto'), audio: 0, sub: 0, session: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), pos: fromStart ? 0 : (m.viewOffset || 0) / 1000, last: 0, timer: null, done: false, sibs: null, speed: 1 }; vp = { id, m, media, part, streams, el, base: 0, hls: null, mode: '', q: store.get('video_q', 'auto'), audio: 0, sub: 0, session: crypto.randomUUID ? crypto.randomUUID() : String(Date.now()), pos: fromStart ? 0 : (m.viewOffset || 0) / 1000, last: 0, timer: null, done: false, sibs: null, speed: 1 };
videoBindMedia(el); videoBindMedia(el);
vp.marks = (m.Marker || []).filter(x => x.type === 'intro' || x.type === 'credits').map(x => ({ type: x.type, a: x.startTimeOffset / 1000, b: x.endTimeOffset / 1000 }));
vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000); vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000);
if (m.type === 'episode') px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`).then(c => { if (vp && vp.id === id) { vp.sibs = c.Metadata || []; vp.sibIdx = vp.sibs.findIndex(x => x.ratingKey === id); vdUi(); } }).catch(() => {}); if (m.type === 'episode') px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`).then(c => { if (vp && vp.id === id) { vp.sibs = c.Metadata || []; vp.sibIdx = vp.sibs.findIndex(x => x.ratingKey === id); vdUi(); } }).catch(() => {});
videoBar(); videoMount(); videoStart(vp.pos); videoMediaSession(); videoBar(); videoMount(); videoStart(vp.pos); videoMediaSession();
@@ -194,7 +235,7 @@ function videoMount() {
let lastTap = 0, lastDbl = 0; let lastTap = 0, lastDbl = 0;
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); }); stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
stage.addEventListener('pointerup', e => { stage.addEventListener('pointerup', e => {
if (e.target.closest('.vctl, .vtop, #vnextbox')) return; if (e.target.closest('.vctl, .vtop, #vnextbox, .vmark')) return;
if (e.pointerType === 'touch') { if (e.pointerType === 'touch') {
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width; const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; } if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
@@ -210,7 +251,7 @@ function videoBindMedia(el) {
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && vp.mode === 'hls' && vp.learn && el.currentTime > 0 && !el.seeking) { // Plex's converted stream may count from 0 (what we asked for starts there) or carry on from the real position: tell which, once el.addEventListener('timeupdate', () => { if (vp && vp.el === el && vp.mode === 'hls' && vp.learn && el.currentTime > 0 && !el.seeking) { // Plex's converted stream may count from 0 (what we asked for starts there) or carry on from the real position: tell which, once
const first = el.buffered.length ? el.buffered.start(0) : el.currentTime; const first = el.buffered.length ? el.buffered.start(0) : el.currentTime;
vp.base = vp.learn > 5 ? (first > vp.learn - 10 ? 0 : Math.floor(vp.learn)) : 0; vp.learn = 0; } vp.base = vp.learn > 5 ? (first > vp.learn - 10 ? 0 : Math.floor(vp.learn)) : 0; vp.learn = 0; }
if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); }); if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); vdMarks(); });
el.addEventListener('play', () => { vdUi(); if (vp && vp.wake) vp.wake(); }); el.addEventListener('play', () => { vdUi(); if (vp && vp.wake) vp.wake(); });
el.addEventListener('pause', () => { vdUi(); if (vp && vp.wake) vp.wake(); if (!el.ended) vdReport('paused'); }); el.addEventListener('pause', () => { vdUi(); if (vp && vp.wake) vp.wake(); if (!el.ended) vdReport('paused'); });
el.addEventListener('volumechange', () => { const mu = $('#vmute'); if (mu) mu.innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); }); el.addEventListener('volumechange', () => { const mu = $('#vmute'); if (mu) mu.innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
@@ -232,6 +273,15 @@ function vdUi() {
const bt = vp.barDrag ? null : $('#vbtm'); if (bt) bt.textContent = vdClock(p) + ' / ' + vdClock(t); const bt = vp.barDrag ? null : $('#vbtm'); if (bt) bt.textContent = vdClock(p) + ' / ' + vdClock(t);
const bpv = $('#vbprev'), bnx = $('#vbnext'); if (bpv) bpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (bnx) bnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1; const bpv = $('#vbprev'), bnx = $('#vbnext'); if (bpv) bpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (bnx) bnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
} }
/** "Skip intro" / "Skip credits" while the position is inside one of Plex's markers for this title. */
function vdMarks() {
if (!vp || !vp.marks || !vp.marks.length) return;
const p = vp.base + vp.el.currentTime, mk = vp.marks.find(x => p >= x.a - 1 && p < x.b - 1), st = $('#vstage'); let b = $('#vmark');
if (!mk || !st) { if (b) b.remove(); return; }
if (!b) { b = document.createElement('button'); b.id = 'vmark'; b.className = 'btn vmark'; st.appendChild(b); }
b.textContent = mk.type === 'intro' ? 'Skip intro' : 'Skip credits';
b.onclick = e => { e.stopPropagation(); if (mk.type === 'credits' && vp.m.duration && mk.b >= vp.m.duration / 1000 - 3) { videoSeek(Math.max(0, vp.m.duration / 1000 - 0.5)); } else videoSeek(mk.b); b.remove(); };
}
/** The mini player: the video keeps playing in a small window while you use other tabs. */ /** The mini player: the video keeps playing in a small window while you use other tabs. */
function videoMini(on) { function videoMini(on) {
let box = document.getElementById('vmini'); let box = document.getElementById('vmini');
+4 -2
View File
@@ -431,7 +431,9 @@ function plexEditRequest(method, url) {
} }
return false; return false;
} }
const PLEX_WRITABLE_URL = (method, url) => PLEX_PLAYLISTS.test(url) || plexEditRequest(method, url); // Taking something off your own Continue Watching row changes nothing about the library, so anyone signed in may do it.
const PLEX_PERSONAL = /^\/plex\/actions\/removeFromContinueWatching\?ratingKey=\d+$/;
const PLEX_WRITABLE_URL = (method, url) => PLEX_PLAYLISTS.test(url) || (method === 'PUT' && PLEX_PERSONAL.test(url)) || plexEditRequest(method, url);
const editOk = new Map(); // token + server -> { t, ok } const editOk = new Map(); // token + server -> { t, ok }
/** Only the server's owner (or an admin of the Home) may edit details: the person's own plex.tv view of the server says so. */ /** Only the server's owner (or an admin of the Home) may edit details: the person's own plex.tv view of the server says so. */
async function plexCanEdit(st) { async function plexCanEdit(st) {
@@ -616,7 +618,7 @@ async function handleRequest(req, res) {
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server'); if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res); if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
if (req.url.startsWith('/api/plex/')) return plexApi(req, res); if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE_URL(req.method, req.url) && (PLEX_PLAYLISTS.test(req.url) || await plexCanEdit(plexLoad()))))) return plexProxy(req, res); if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE_URL(req.method, req.url) && (PLEX_PLAYLISTS.test(req.url) || (req.method === 'PUT' && PLEX_PERSONAL.test(req.url)) || await plexCanEdit(plexLoad()))))) return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res); if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
return serveStatic(req, res); return serveStatic(req, res);
}); });