diff --git a/public/style.css b/public/style.css
index 8ec4244..e1e806d 100644
--- a/public/style.css
+++ b/public/style.css
@@ -750,3 +750,12 @@ body.nav-mini .ngrp span { display: none; }
/* Home never scrolls sideways: wide content shrinks or wraps instead */
.home, .home .hcol, .home .homeapps { min-width: 0; max-width: 100%; }
#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; }
diff --git a/public/sw.js b/public/sw.js
index 1d279db..1abb2c2 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
// 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 SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
diff --git a/public/video.js b/public/video.js
index 68bd5db..58915c4 100644
--- a/public/video.js
+++ b/public/video.js
@@ -43,14 +43,23 @@ async function viewVideo(args = []) {
}
// ---- 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 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);
- return `
+ const html = `
${thumb ? `
` : `${icon('film')}`}${seen ? `${icon('check')}` : ''}${pct ? `` : ''}
- ${esc(m.type === 'episode' && wide ? m.title : m.title)}${m.type === 'episode' ? vdEp(m) : esc([m.year, m.type === 'show' && m.leafCount ? (m.leafCount - (m.viewedLeafCount || 0)) + ' unwatched' : ''].filter(Boolean).join(' · '))}`;
+ ${esc(m.type === 'episode' && wide ? m.title : m.title)}${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(' · '))}`;
+ return deck ? `
${html}
` : 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) {
const v = $('#view');
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);
v.innerHTML = `Movies & TV
${secs.length > 1 ? `
` : ''}
-
${esc(cur.title)}
`;
- // Continue watching and the library load side by side
- px.get('/library/onDeck?X-Plex-Container-Start=0&X-Plex-Container-Size=12').then(mc => {
- const items = (mc.Metadata || []).filter(m => m.type === 'movie' || m.type === 'episode'); if (!items.length || !$('#vdeck')) return;
- $('#vdeck').innerHTML = `Continue watching
${items.map(m => vdCard(m, true)).join('')}
`;
+ ${esc(cur.title)}
+ `;
+ // 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; } };
+ 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 ? `${title}${deck ? '' : ''}
${items.map(m => vdCard(m, true, deck)).join('')}
` : ''; };
+ 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(() => {});
- 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 = `${title}
${c.map(m => vdCard(m, false)).join('')}
`; }
+ 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 ? `${title}
${items.map(m => vdCard(m, false)).join('')}
` : '';
+ }).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 => ``).join('')); }).catch(() => {});
+ let start = 0, total = 0, all = [], gen = 0;
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}`);
- if (!$('#vgrid')) return; total = mc.totalSize || mc.size || 0; const items = mc.Metadata || []; start += items.length; all = all.concat(items);
+ 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') || 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();
$('#vmore').innerHTML = start < total ? `` : '';
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
- if (!all.length) $('#vgrid').innerHTML = 'Nothing in this library.
';
+ if (!all.length) $('#vgrid').innerHTML = 'Nothing matches.
';
};
- $('#vgrid').innerHTML = ''; load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `${esc(e.message)}
`; });
+ const reload = () => { gen++; start = 0; total = 0; all = []; $('#vgrid').innerHTML = 'Loading…
'; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = ''; load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `${esc(e.message)}
`; }); };
+ $('#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 => {
clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; }
t = setTimeout(async () => {
@@ -144,7 +184,7 @@ const loadHls = () => hlsLib || (hlsLib = new Promise((ok, no) => { if (window.H
let vdHooked = false;
async function videoWatch(id) {
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 = 'That item has no playable file.
'; return; }
videoStop();
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)));
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);
+ 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);
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();
@@ -194,7 +235,7 @@ function videoMount() {
let lastTap = 0, lastDbl = 0;
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
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') {
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; }
@@ -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
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; }
- 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('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'); });
@@ -232,6 +273,15 @@ function vdUi() {
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;
}
+/** "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. */
function videoMini(on) {
let box = document.getElementById('vmini');
diff --git a/server.js b/server.js
index 46971df..3adac92 100644
--- a/server.js
+++ b/server.js
@@ -431,7 +431,9 @@ function plexEditRequest(method, url) {
}
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 }
/** 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) {
@@ -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/mail/')) return mail.handle(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);
return serveStatic(req, res);
});