Movies & TV: custom player controls (seek bar, skip 10 s, previous and next episode, volume, speed, picture in picture), breadcrumbs and library pills on every page

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 11:04:11 -04:00
1 parent 2b79026157
commit f232dc32d3
4 files changed
+154 -31

No files matched your search

+5
View File
@@ -76,6 +76,11 @@ const ICONS = {
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
rew: '<path d="M12 5V2L7 6l5 4V7a6 6 0 11-6 6"/>',
fwd: '<path d="M12 5V2l5 4-5 4V7a6 6 0 106 6"/>',
vol: '<path d="M4 9v6h4l5 4V5L8 9H4zM16.5 8.5a5 5 0 010 7M19 6a8.5 8.5 0 010 12"/>',
mute: '<path d="M4 9v6h4l5 4V5L8 9H4zM17 9l4 6M21 9l-4 6"/>',
pip: '<rect x="3" y="5" width="18" height="14" rx="2"/><rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"/>',
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
+46 -11
View File
@@ -599,9 +599,7 @@ body.nav-mini .ngrp span { display: none; }
.vbg { position: absolute; inset: -8px -16px auto -16px; width: calc(100% + 32px); height: 220px; object-fit: cover; opacity: .18; z-index: -1; mask-image: linear-gradient(#000, transparent); -webkit-mask-image: linear-gradient(#000, transparent); }
.vep { align-items: flex-start; }
.vwrap { max-width: 1280px; margin: 0 auto; }
.vbar { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.vtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vwrap video { width: 100%; max-height: 78dvh; background: #000; border-radius: 12px; display: block; }
.vnext { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; padding: 12px 14px; border-radius: 12px; background: var(--panel2); }
.vnext b { flex: 1; min-width: 160px; }
@media (min-width: 768px) { .vgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } .vpost { width: 190px; } }
@@ -609,12 +607,49 @@ body.nav-mini .ngrp span { display: none; }
.vrow { scrollbar-width: thin; }
.vrow::-webkit-scrollbar { height: 6px; } .vrow::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
.vwrap.vfull { position: fixed; inset: 0; z-index: 90; max-width: none; background: #000; display: flex; flex-direction: column; padding: calc(var(--sat) + 6px) 8px calc(var(--sab) + 6px); }
.vwrap.vfull video { flex: 1; min-height: 0; max-height: none; border-radius: 0; }
.vwrap.vfull .vbar { position: absolute; top: calc(var(--sat) + 8px); left: 8px; right: 8px; z-index: 2; opacity: 0; transition: opacity .2s; }
.vwrap.vfull:hover .vbar, .vwrap.vfull .vbar:focus-within { opacity: 1; }
.vwrap:fullscreen { background: #000; max-width: none; display: flex; flex-direction: column; padding: 6px 8px; }
.vwrap:fullscreen video { flex: 1; min-height: 0; max-height: none; border-radius: 0; }
.vwrap:fullscreen .vbar { position: absolute; top: 8px; left: 8px; right: 8px; z-index: 2; opacity: 0; transition: opacity .2s; }
.vwrap:fullscreen:hover .vbar, .vwrap:fullscreen .vbar:focus-within { opacity: 1; }
@media (hover: none) { .vwrap.vfull .vbar, .vwrap:fullscreen .vbar { opacity: 1; } }
/* Movies & TV player controls */
.vstage { position: relative; background: #000; border-radius: 12px; overflow: hidden; user-select: none; -webkit-user-select: none; }
.vstage video { width: 100%; max-height: 78dvh; display: block; background: #000; }
.vstage.vhide { cursor: none; }
.vtop, .vctl { position: absolute; left: 0; right: 0; z-index: 3; color: #fff; transition: opacity .25s; }
.vtop { top: 0; display: flex; align-items: center; gap: 10px; padding: 10px 12px 28px; background: linear-gradient(rgba(0, 0, 0, .7), transparent); }
.vctl { bottom: 0; padding: 26px 12px 8px; background: linear-gradient(transparent, rgba(0, 0, 0, .8)); }
.vstage.vhide .vtop, .vstage.vhide .vctl { opacity: 0; pointer-events: none; }
.vtop .btn { background: rgba(255, 255, 255, .15); color: #fff; border: 0; min-height: 38px; }
.vtitle { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.vseek { display: flex; align-items: center; gap: 10px; font-size: 13px; font-variant-numeric: tabular-nums; }
.vseek input { flex: 1; margin: 0; }
.vctl input[type=range] { -webkit-appearance: none; appearance: none; height: 22px; background: transparent; accent-color: var(--accent); cursor: pointer; }
.vctl input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .35); }
.vctl input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: #fff; margin-top: -5px; }
.vctl input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .35); }
.vctl input[type=range]::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #fff; }
.vrow2 { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
.vleft, .vright { display: flex; align-items: center; gap: 4px; }
.vb { border: 0; background: transparent; color: #fff; min-width: 44px; height: 44px; border-radius: 10px; display: inline-grid; place-items: center; cursor: pointer; font: inherit; }
.vb:hover { background: rgba(255, 255, 255, .18); }
.vb:disabled { opacity: .35; cursor: default; }
.vb svg { width: 24px; height: 24px; }
.vb.big { width: 54px; height: 54px; background: rgba(255, 255, 255, .2); border-radius: 50%; } .vb.big svg { width: 28px; height: 28px; }
.vb.txt { padding: 0 10px; font-size: 14px; }
.vskip { position: relative; display: grid; place-items: center; } .vskip em { position: absolute; font-style: normal; font-size: 9px; font-weight: 700; top: 8px; }
.vright input[type=range] { width: 80px; }
.vflash { position: absolute; left: 50%; top: 42%; transform: translate(-50%, -50%) scale(.9); z-index: 4; padding: 10px 18px; border-radius: 30px; background: rgba(0, 0, 0, .65); color: #fff; font-size: 22px; opacity: 0; pointer-events: none; }
.vflash.on { animation: vflash .7s ease-out; } @keyframes vflash { 0% { opacity: 1; transform: translate(-50%, -50%) scale(1); } 70% { opacity: 1; } 100% { opacity: 0; transform: translate(-50%, -60%) scale(1.05); } }
.vspin { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 3; pointer-events: none; }
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; }
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
.vstage:fullscreen video { height: 100%; }
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
.vnav { display: flex; align-items: center; gap: 10px 14px; flex-wrap: wrap; margin-bottom: 10px; }
.vcrumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: 14px; color: var(--muted); }
.vcrumbs a { color: var(--muted); } .vcrumbs a:hover { color: var(--text); } .vcrumbs i { font-style: normal; opacity: .6; }
.vnav .sgrp { margin-left: auto; }
.vtop .vcrumbs { color: rgba(255, 255, 255, .8); } .vtop .vcrumbs a { color: rgba(255, 255, 255, .8); }
@media (max-width: 700px) { .vtop .vtc { display: none; } .vnav .sgrp { margin-left: 0; } }
+102 -19
View File
@@ -9,6 +9,24 @@ const vdClock = s => { s = Math.max(0, Math.round(s)); const h = Math.floor(s /
const vdEp = m => m.type === 'episode' ? `${esc(m.grandparentTitle || '')} · S${m.parentIndex || '?'}E${m.index || '?'}` : '';
const vdPct = m => m.viewOffset && m.duration ? Math.min(100, Math.round(100 * m.viewOffset / m.duration)) : 0;
let vdLibCache = { t: 0, v: null };
async function vdLibs() {
if (vdLibCache.v && Date.now() - vdLibCache.t < 5 * 60e3) return vdLibCache.v;
const v = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show').map(d => ({ key: d.key, title: d.title })); vdLibCache = { t: Date.now(), v }; return v;
}
/** Where you are in Movies & TV, with a way back at every level: the library, the show, the season. (Also used in the player.) */
const vdCrumbs = m => {
const c = [`<a href="#/video/lib/${esc(m.librarySectionID || '')}">${esc(m.librarySectionTitle || 'Library')}</a>`];
if (m.type === 'season') c.push(`<a href="#/video/item/${m.parentRatingKey}">${esc(m.parentTitle || 'Show')}</a>`);
if (m.type === 'episode') { c.push(`<a href="#/video/item/${m.grandparentRatingKey}">${esc(m.grandparentTitle || 'Show')}</a>`); c.push(`<a href="#/video/item/${m.parentRatingKey}">Season ${m.parentIndex || ''}</a>`); }
return c.join('<i>›</i>');
};
/** The strip at the top of an item page: Back, the breadcrumb, and a pill for every library (Movies, TV Shows, ...). */
async function vdNav(m, back) {
let libs = []; try { libs = await vdLibs(); } catch {}
return `<div class="vnav"><a class="btn ghost" href="${back}">${icon('back')}Back</a><nav class="vcrumbs" aria-label="Where you are">${vdCrumbs(m)}</nav>
${libs.length ? `<div class="sgrp" role="navigation" aria-label="Libraries">${libs.map(l => `<a href="#/video/lib/${l.key}" class="${String(l.key) === String(m.librarySectionID) ? 'on' : ''}">${esc(l.title)}</a>`).join('')}</div>` : ''}</div>`;
}
async function viewVideo(args = []) {
const v = $('#view'), [kind, key] = args;
if (kind !== 'watch') videoStop();
@@ -82,7 +100,7 @@ async function videoItem(id) {
const meta = [m.year, m.contentRating, m.duration ? vdTime(m.duration) : '', res, m.audienceRating ? '★ ' + m.audienceRating : (m.rating ? '★ ' + m.rating : '')].filter(Boolean).join(' · ');
const playable = m.type === 'movie' || m.type === 'episode';
const next = m.OnDeck && m.OnDeck.Metadata && m.OnDeck.Metadata[0];
v.innerHTML = `<div class="wrap vdetail"><div class="bar"><a class="btn ghost" href="${back}">${icon('back')}Back</a></div>
v.innerHTML = `<div class="wrap vdetail">${await vdNav(m, back)}
<div class="vhero">${m.art ? `<img class="vbg" alt="" src="${vdArt(m.art, 1000, 560)}" onerror="this.remove()">` : ''}
<div class="vhead"><span class="vimg vpost" style="aspect-ratio:2/3">${m.thumb ? `<img alt="" src="${vdArt(m.thumb, 300, 450)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}</span>
<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>
@@ -128,28 +146,87 @@ async function videoWatch(id) {
try { pl.a.pause(); } catch {}
const media = m.Media[0], part = media.Part[0], streams = part.Stream || [], fromStart = store.get('video_from_start') === id;
if (fromStart) store.del('video_from_start');
vp = { id, m, media, part, streams, 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 };
vp = { id, m, media, part, streams, 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 };
document.body.classList.add('onvideo');
const title = m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title;
v.innerHTML = `<div class="vwrap"><div class="vbar"><a class="btn ghost" id="vback" href="${m.type === 'episode' ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><b class="vtitle">${esc(title)}</b><button class="btn ghost" id="vfs" aria-label="Full screen">Full screen</button><button class="btn ghost" id="vset">Quality & tracks</button></div>
<video id="vv" controls controlsList="nofullscreen" playsinline autoplay></video><p class="sub" id="vmsg" role="status"></p><div id="vnextbox"></div></div>`;
const el = $('#vv'); vp.el = el;
const title = m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title, ep = m.type === 'episode';
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage"><video id="vv" playsinline autoplay></video>
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b></div>
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div>
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', '1×', 'Speed', ' txt')}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}</div></div></div>
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
const el = $('#vv'), stage = $('#vstage'); vp.el = el;
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
el.volume = Math.max(0, Math.min(1, +store.get('video_vol', 1))); $('#vvol').value = Math.round(el.volume * 100);
const total = () => (vp.m.duration || 0) / 1000, here = () => vp.base + el.currentTime;
let drag = false, hideT = null;
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
const paint = () => {
const t = total() || el.duration || 0, p = here();
if (!drag) { $('#vrange').value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; }
$('#vcur').textContent = vdClock(drag ? $('#vrange').value / 1000 * t : p); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - (drag ? $('#vrange').value / 1000 * t : p)));
};
const flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
vp.flash = flash; vp.wake = wake;
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) { vp.pos = vp.base + el.currentTime; } });
el.addEventListener('playing', () => { msg(''); vdReport('playing'); });
el.addEventListener('pause', () => { if (!el.ended) vdReport('paused'); });
el.addEventListener('waiting', () => { vp.waitSince = Date.now(); if (vp.mode === 'hls') setTimeout(() => { if (vp && vp.el === el && vp.waitSince && Date.now() - vp.waitSince > 9000 && el.readyState < 3) { msg('Taking a while… restarting from here'); videoStart(vp.base + el.currentTime); } }, 9500); });
el.addEventListener('canplay', () => { if (vp) vp.waitSince = 0; });
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
$('#vmute').onclick = () => { el.muted = !el.muted; };
$('#vvol').oninput = e => { el.volume = e.target.value / 100; el.muted = el.volume === 0; store.set('video_vol', el.volume); };
$('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el.playbackRate = r; $('#vspd').textContent = r + '×'; } })));
const range = $('#vrange');
range.oninput = () => { drag = true; paint(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
// clicks: mouse click plays or pauses, double click goes full screen; touch: a tap shows or hides the controls, a double tap on the left or right side skips 10 s
let lastTap = 0;
stage.addEventListener('pointerup', e => {
if (e.target.closest('.vctl, .vtop, #vnextbox')) 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; }
lastTap = now; if (stage.classList.contains('vhide')) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
} else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); }
});
let lastDbl = 0; stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; paint(); });
el.addEventListener('play', () => { $('#vpp').innerHTML = icon('pause'); $('#vpp').setAttribute('aria-label', 'Pause'); wake(); });
el.addEventListener('pause', () => { $('#vpp').innerHTML = icon('play'); $('#vpp').setAttribute('aria-label', 'Play'); wake(); if (!el.ended) vdReport('paused'); });
el.addEventListener('volumechange', () => { $('#vmute').innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
el.addEventListener('playing', () => { msg(''); $('#vspin').hidden = true; vdReport('playing'); });
el.addEventListener('waiting', () => { $('#vspin').hidden = false; vp.waitSince = Date.now(); if (vp.mode === 'hls') setTimeout(() => { if (vp && vp.el === el && vp.waitSince && Date.now() - vp.waitSince > 9000 && el.readyState < 3) { msg('Taking a while… restarting from here'); videoStart(vp.base + el.currentTime); } }, 9500); });
el.addEventListener('canplay', () => { $('#vspin').hidden = true; if (vp) vp.waitSince = 0; });
el.addEventListener('ended', () => videoEnded());
el.addEventListener('error', () => { if (vp && vp.mode === 'direct') { msg('The browser cannot play this file directly. Converting it…'); vp.q = 'auto'; vp.forceHls = true; videoStart(vp.pos); } else msg('Could not play this. Try a lower quality in Quality & tracks.'); });
vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000);
videoStart(vp.pos);
if (ep) 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); const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = vp.sibIdx <= 0; if (nx) nx.disabled = vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1; } }).catch(() => {});
paint(); videoStart(vp.pos);
}
/** Jump to a point in the whole film or episode. A converted stream only covers what Plex has made from its start point, so a seek outside it restarts the conversion there. */
function videoSeek(abs) {
if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000;
abs = Math.max(0, t ? Math.min(abs, t - 1) : abs);
if (vp.mode === 'direct') { el.currentTime = abs; return; }
const rel = abs - vp.base;
if (rel >= 0 && isFinite(el.duration) && rel < el.duration - 2) el.currentTime = rel; else videoStart(abs);
}
function videoSkip(d) {
if (!vp) return; videoSeek(vp.base + vp.el.currentTime + d);
if (vp.flash) vp.flash((d < 0 ? '−' : '+') + Math.abs(d) + ' s');
}
/** Previous or next episode (or, for the previous one, back to the start if you are more than 5 seconds in). */
function videoGo(dir) {
if (!vp || !vp.sibs) return;
if (dir < 0 && vp.base + vp.el.currentTime > 5) { videoSeek(0); if (vp.flash) vp.flash('Restart'); return; }
const t = vp.sibs[vp.sibIdx + dir]; if (t) location.hash = `#/video/watch/${t.ratingKey}`;
}
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
function videoFullscreen() {
if (!vp) return; const box = document.querySelector('.vwrap'), el = vp.el;
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el;
const native = document.fullscreenElement || document.webkitFullscreenElement;
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
@@ -206,9 +283,9 @@ async function videoEnded() {
px.get(`/:/scrobble?key=${id}&identifier=com.plexapp.plugins.library`).catch(() => {}); tlSend('stopped', id, m.duration || 0, m.duration || 0);
if (m.type !== 'episode') return;
try {
const leaves = (await px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`)).Metadata || [], i = leaves.findIndex(x => x.ratingKey === id), nx = leaves[i + 1];
const leaves = vp.sibs || (await px.get(`/library/metadata/${m.grandparentRatingKey}/allLeaves`)).Metadata || [], i = leaves.findIndex(x => x.ratingKey === id), nx = leaves[i + 1];
if (!nx || !vp || vp.id !== id) return;
const box = $('#vnextbox'); if (!box) return; let n = 10;
const box = $('#vnextbox'); if (!box) return; let n = 10; vp.wake && vp.wake();
box.innerHTML = `<div class="vnext"><b>Up next: S${nx.parentIndex}E${nx.index} · ${esc(nx.title)}</b><span id="vcount">in ${n}s</span><button class="btn" id="vgo">Play now</button><button class="btn ghost" id="vcx">Stop</button></div>`;
const go = () => { clearInterval(tm); location.hash = `#/video/watch/${nx.ratingKey}`; };
const tm = setInterval(() => { n--; const c = $('#vcount'); if (!c) return clearInterval(tm); c.textContent = 'in ' + n + 's'; if (n <= 0) go(); }, 1000);
@@ -225,9 +302,15 @@ function videoStop() {
addEventListener('hashchange', () => { if (vp && !location.hash.startsWith('#/video/watch/')) videoStop(); });
addEventListener('pagehide', () => { if (vp) { try { navigator.sendBeacon && navigator.sendBeacon('/plex/video/:/transcode/universal/stop?session=' + vp.session); } catch {} vdReport('stopped'); } });
addEventListener('keydown', e => {
if (!vp || !vp.el || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '') || e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet')) return;
const el = vp.el, k = e.key;
if (!vp || !vp.el || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '') && e.target.type !== 'range' || e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet')) return;
const el = vp.el, k = e.key; if (vp.wake) vp.wake();
if (e.target.type === 'range' && (k === 'ArrowLeft' || k === 'ArrowRight')) return; // (the sliders use their own arrows)
if (k === ' ' || k === 'k') { e.preventDefault(); el.paused ? el.play() : el.pause(); }
else if (k === 'ArrowRight') { e.preventDefault(); el.currentTime += 10; } else if (k === 'ArrowLeft') { e.preventDefault(); el.currentTime = Math.max(0, el.currentTime - 10); }
else if (k === 'f') { e.preventDefault(); videoFullscreen(); } else if (k === 'Escape') { const b = document.querySelector('.vwrap.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } } else if (k === 'm') el.muted = !el.muted;
else if (k === 'ArrowRight' || k === 'l') { e.preventDefault(); videoSkip(k === 'l' || e.shiftKey ? 30 : 10); } else if (k === 'ArrowLeft' || k === 'j') { e.preventDefault(); videoSkip(k === 'j' || e.shiftKey ? -30 : -10); }
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } }
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
}, true);