From c49693bac6a272a592cfa8ea5874b7b12924affa Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:21:03 -0400 Subject: [PATCH] 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 --- public/style.css | 9 ++++++ public/sw.js | 2 +- public/video.js | 82 ++++++++++++++++++++++++++++++++++++++---------- server.js | 6 ++-- 4 files changed, 80 insertions(+), 19 deletions(-) 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 ? `
${secs.map(s => `${esc(s.title)}`).join('')}
` : ''}
-

${esc(cur.title)}

Loading…

`; - // 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)}

+

Loading…

`; + // 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); });