'use strict';
// Movies & TV from your Plex server (the same Plex sign-in and Plex Home user as Music). Browse, search, resume, play, mark watched.
// The player plays the file straight when the browser can, otherwise asks Plex to convert it as it plays (HLS, with hls.js where the browser has no HLS of its own).
const vd = { libs: null, onDeck: null };
const vdEnc = encodeURIComponent;
const vdArt = (thumb, w = 300, h = 450) => thumb ? `/plex/photo/:/transcode?width=${w}&height=${h}&minSize=1&upscale=1&url=${vdEnc(thumb)}` : '';
const vdTime = ms => { const s = Math.round((ms || 0) / 1000), h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? `${h}h ${String(m).padStart(2, '0')}m` : `${m}m`; };
const vdClock = s => { s = Math.max(0, Math.round(s)); const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(s % 60).padStart(2, '0'); };
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 = [`${esc(m.librarySectionTitle || 'Library')}`];
if (m.type === 'season') c.push(`${esc(m.parentTitle || 'Show')}`);
if (m.type === 'episode') { c.push(`${esc(m.grandparentTitle || 'Show')}`); c.push(`Season ${m.parentIndex || ''}`); }
return c.join('›');
};
/** 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 `
`; }
}, 350);
};
}
// ---- A movie, a show, a season or an episode
async function videoItem(id) {
const v = $('#view');
const mc = await px.get(`/library/metadata/${id}?includeOnDeck=1`), m = (mc.Metadata || [])[0];
if (!m) { v.innerHTML = '
`;
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
$('#vvol').value = Math.round(el.volume * 100);
const total = () => (vp.m.duration || 0) / 1000;
let drag = false, hideT = null;
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
vp.wake = wake; vp.drag = () => drag;
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
$('#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);
if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el);
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
$('#vmute').onclick = () => { el.muted = !el.muted; };
$('#vclose').onclick = () => { const back = $('#vback').getAttribute('href'); videoStop(); location.hash = back; };
$('#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; vdUi(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
let lastTap = 0, lastDbl = 0;
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
stage.addEventListener('pointerup', e => {
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; }
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(); }
});
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
if (m.type === 'clip' || m.extraType) { const bk = $('#vback'); if (bk) bk.setAttribute('href', store.get('video_back') || '#/video'); } // a trailer: Back returns to its movie
vdUi(); wake();
}
/** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */
function videoBindMedia(el) {
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
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(); 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'); });
el.addEventListener('playing', () => { msg(''); const s = $('#vspin'); if (s) s.hidden = true; vdReport('playing'); });
el.addEventListener('waiting', () => { const s = $('#vspin'); if (s) s.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', () => { const s = $('#vspin'); if (s) s.hidden = true; if (vp) vp.waitSince = 0; });
el.addEventListener('ended', () => { // a converted stream can report "ended" when it has only caught up with what Plex has made so far: carry on from there instead of stopping
const at = vp ? vp.base + el.currentTime : 0, total = vp && vp.m && vp.m.duration ? vp.m.duration / 1000 : 0;
if (vp && vp.el === el && total && at < total - 45 && videoRescue('ended early', at)) return; videoEnded(); });
if (!el._wd) el._wd = setInterval(() => { // nothing has moved for a while although it should be playing: start again from the same place
if (!vp || vp.el !== el) { clearInterval(el._wd); el._wd = 0; return; }
if (el.paused || el.ended || el.seeking || vp.opening || document.visibilityState !== 'visible') { vp.still = 0; vp.lastT = el.currentTime; return; }
if (Math.abs(el.currentTime - (vp.lastT || 0)) < 0.2) { if ((vp.still = (vp.still || 0) + 1) >= 3) { vp.still = 0; videoRescue('stalled', vp.base + el.currentTime); } } else vp.still = 0;
vp.lastT = el.currentTime; }, 4000);
el.addEventListener('error', () => { if (vp && vp.el === el && 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.'); });
}
/** Refresh whatever controls are on screen (the player page's, and the mini player's) from the video's state. */
function vdUi() {
if (!vp || !vp.el) return; const el = vp.el, t = (vp.m.duration || 0) / 1000 || el.duration || 0, p = vp.base + el.currentTime, drag = vp.drag && vp.drag();
const r = $('#vrange');
if (r) { if (!drag) r.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = drag ? r.value / 1000 * t : p; $('#vcur').textContent = vdClock(shown); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - shown)); }
const pp = $('#vpp'); if (pp) { pp.innerHTML = icon(el.paused ? 'play' : 'pause'); pp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = !vp.sibs || vp.sibIdx <= 0; if (nx) nx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
const bp = $('#vbpp'); if (bp) { bp.innerHTML = icon(el.paused ? 'play' : 'pause'); bp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
const bi = vp.barDrag ? null : $('#vbp'); if (bi) bi.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%';
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');
if (!on) { if (box) box.hidden = true; return; }
if (!vp || !vp.el) return;
if (!box) {
box = document.createElement('div'); box.id = 'vmini'; box.setAttribute('role', 'region'); box.setAttribute('aria-label', 'Video (click to open the player)');
box.innerHTML = '';
document.body.appendChild(box); box.querySelector('.vmslot').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
}
box.hidden = false; vp.el.controls = false; box.querySelector('.vmslot').appendChild(vp.el); document.body.classList.remove('onvideo'); document.body.classList.remove('vfullwin');
const st = document.querySelector('.vstage.vfull'); if (st) st.classList.remove('vfull');
vdUi();
}
/** The bottom bar (the same one Music uses) shows the video's controls on every tab: poster, title, time, previous and next episode, back and forward 10 s, play/pause, expand, close. */
const videoBarOn = () => !!(vp && vp.el);
function videoBar() {
const el = $('#player'); if (!el || !vp) return; const m = vp.m, ep = m.type === 'episode';
el.hidden = false;
el.innerHTML = `
${ep ? `` : ''}${ep ? `` : ''}
`;
const q = s => el.querySelector(s);
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
const sk = q('#vbseek'), frac = e => { const r = sk.getBoundingClientRect(); return Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)); }, total = () => (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
const preview = f => { const t = total(); q('#vbp').style.width = (f * 100) + '%'; q('#vbtm').textContent = vdClock(f * t) + ' / ' + vdClock(t); };
sk.onpointerdown = e => {
if (!vp) return; sk.setPointerCapture(e.pointerId); vp.barDrag = true; let f = frac(e); preview(f);
sk.onpointermove = ev => { f = frac(ev); preview(f); };
sk.onpointerup = ev => { sk.onpointermove = sk.onpointerup = sk.onpointercancel = null; f = frac(ev); vp.barDrag = false; videoSeek(f * total()); vdUi(); };
sk.onpointercancel = () => { sk.onpointermove = sk.onpointerup = sk.onpointercancel = null; vp.barDrag = false; vdUi(); };
};
vdUi();
}
/** Lock-screen and media-key controls. */
function videoMediaSession() {
if (!('mediaSession' in navigator) || !vp) return;
try {
navigator.mediaSession.metadata = new MediaMetadata({ title: vp.m.title, artist: vp.m.type === 'episode' ? vp.m.grandparentTitle : 'Movie', artwork: vp.m.thumb ? [{ src: vdArt(vp.m.thumb, 300, 450), sizes: '300x450' }] : [] });
const set = (a, f) => { try { navigator.mediaSession.setActionHandler(a, f); } catch {} };
set('play', () => vp && vp.el.play()); set('pause', () => vp && vp.el.pause()); set('seekbackward', () => videoSkip(-10)); set('seekforward', () => videoSkip(10));
set('previoustrack', vp.m.type === 'episode' ? () => videoGo(-1) : null); set('nexttrack', vp.m.type === 'episode' ? () => videoGo(1) : null);
} catch {}
}
/** 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, sk = el.seekable;
// Only inside what the converted stream really has (its range may begin late when it started part-way); anything else restarts the conversion there.
const inside = sk && sk.length ? rel >= sk.start(0) - 0.5 && rel <= sk.end(sk.length - 1) - 2 : rel >= 0 && isFinite(el.duration) && rel < el.duration - 2;
if (inside) el.currentTime = rel; else videoStart(abs);
}
function videoSkip(d) {
if (!vp) return; const to = Math.max(0, vp.base + vp.el.currentTime + d), t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
const bp = $('#vbp'), bt = $('#vbtm'); if (t) { if (bp) bp.style.width = Math.min(100, 100 * to / t) + '%'; if (bt) bt.textContent = vdClock(to) + ' / ' + vdClock(t); } // (the bar moves before the video has finished seeking)
videoSeek(to);
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('.vstage'), el = vp.el; if (!box) return;
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; }
const req = box.requestFullscreen || box.webkitRequestFullscreen;
const fallback = () => { if (el.webkitEnterFullscreen && /iPhone|iPad|iPod/.test(navigator.userAgent)) { try { el.webkitEnterFullscreen(); return; } catch {} } box.classList.add('vfull'); document.body.classList.add('vfullwin'); };
if (!req) return fallback();
try { // some embedded browsers accept the request and then do nothing: if full screen has not started a moment later, use the full-window mode
const p = req.call(box); if (p && p.catch) p.catch(fallback);
setTimeout(() => { if (!document.fullscreenElement && !document.webkitFullscreenElement && !box.classList.contains('vfull') && vp) fallback(); }, 450);
} catch { fallback(); }
}
document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; });
/** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */
function videoRescue(why, at) {
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
vp.rescues = (vp.rescues || 0) + 1; vp.rescueAt = now; const e = $('#vmsg'); if (e) e.textContent = 'Connection hiccup. Picking up where it stopped…'; videoStart(at); return true;
}
/** Start (or restart) playing from `sec` with the current quality and tracks. */
async function videoStart(sec) {
if (!vp) return; const el = vp.el, { m, media, part } = vp; sec = Math.max(0, sec || 0); if (m.duration && sec * 1000 > m.duration - 15000) sec = 0;
if (vp.hls) { try { vp.hls.destroy(); } catch {} vp.hls = null; }
fetch('/plex/video/:/transcode/universal/stop?session=' + vp.session).catch(() => {});
vp.session = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
const q = QUALITIES.find(x => x[0] === vp.q) || QUALITIES[0], direct = !vp.forceHls && (vp.q === 'original' || (vp.q === 'auto' && canDirect(media) && !vp.audio && !vp.sub));
if (direct) {
vp.mode = 'direct'; vp.base = 0; vp.learn = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true });
const t = $('#vmsg'); if (t) t.textContent = '';
} else {
vp.mode = 'hls'; vp.base = sec; vp.learn = sec || 0.001;
const o = q[2] || QUALITIES[0][2], p = new URLSearchParams({ path: '/library/metadata/' + vp.id, mediaIndex: 0, partIndex: 0, protocol: 'hls', fastSeek: 1, directPlay: 0, directStream: 1, subtitleSize: 100, audioBoost: 100, offset: Math.floor(sec), session: vp.session, maxVideoBitrate: o.kbps, 'X-Plex-Platform': 'Chrome' });
if (o.res) p.set('videoResolution', o.res); if (vp.audio) p.set('audioStreamID', vp.audio); if (vp.sub) { p.set('subtitleStreamID', vp.sub); p.set('subtitles', 'burn'); }
const url = '/plex/video/:/transcode/universal/start.m3u8?' + p; const t = $('#vmsg'); if (t) t.textContent = 'Preparing the stream…';
// hls.js first wherever the browser can run it (iPad included): it lets us keep a long buffer, so a short drop in the connection does not stop the picture, and it
// starts as soon as the first piece arrives. The built-in player (which decides its own small buffer) is the fallback.
let Hls = null; try { Hls = await loadHls(); } catch {} if (!vp || vp.el !== el) return;
const native = !!el.canPlayType('application/vnd.apple.mpegurl');
if (Hls && Hls.isSupported() && !vp.noJs) {
const h = vp.hls = new Hls({ maxBufferLength: 120, maxMaxBufferLength: 600, maxBufferSize: 120 * 1e6, backBufferLength: 30, startFragPrefetch: true, testBandwidth: false, enableWorker: true, maxBufferHole: 0.5 });
h.loadSource(url); h.attachMedia(el);
h.on(Hls.Events.ERROR, (e, d) => { if (!d.fatal) return; const tt = $('#vmsg');
if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { if (tt) tt.textContent = 'Connection problem. Retrying…'; h.startLoad(); }
else if (d.type === Hls.ErrorTypes.MEDIA_ERROR && Date.now() - (vp.recovered || 0) > 5000) { vp.recovered = Date.now(); h.recoverMediaError(); }
else if (native && vp && vp.el === el) { vp.noJs = true; videoStart(vp.base + el.currentTime); } // (the built-in player takes over)
else if (tt) tt.textContent = 'Could not play this. Try a lower quality in Quality & tracks.'; });
} else if (native) { el.src = url; }
else { const tt = $('#vmsg'); if (tt) tt.textContent = 'This browser cannot play converted video'; return; }
}
el.play().catch(e => { const t = $('#vmsg'); if (t && e && e.name === 'NotAllowedError') t.textContent = 'Tap the play button to start'; }); // (the browser only lets a tap start the picture)
}
const vdReport = state => { if (!vp) return; const pos = (vp.base + (vp.el ? vp.el.currentTime : 0)) * 1000; tlSend(state, vp.id, pos, vp.m.duration || 0); };
function videoSettings() {
if (!vp) return;
const aud = vp.streams.filter(s => s.streamType === 2), sub = vp.streams.filter(s => s.streamType === 3);
const here = () => vp.base + (vp.el ? vp.el.currentTime : 0);
const el = openSheet('Quality & tracks', vp.mode === 'direct' ? 'Playing the original file' : 'Plex is converting this for your browser', `
${aud.length > 1 ? `` : ''}
${sub.length ? `
Subtitles are drawn into the picture by Plex, so choosing one converts the video.
` : ''}
`);
el.querySelector('#sx').onclick = closeSheet;
el.querySelector('#vapply').onclick = () => {
vp.q = el.querySelector('#vq2').value; store.set('video_q', vp.q); vp.audio = el.querySelector('#va2') ? +el.querySelector('#va2').value : 0; vp.sub = el.querySelector('#vs2') ? +el.querySelector('#vs2').value : 0; vp.forceHls = false;
const at = here(); closeSheet(); videoStart(at);
};
}
/** The end: mark watched and, for TV, offer the next episode. */
async function videoEnded() {
if (!vp || vp.done) return; vp.done = true; const { id, m } = vp;
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 = 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; vp.wake && vp.wake();
box.innerHTML = `
Up next: S${nx.parentIndex}E${nx.index} · ${esc(nx.title)}in ${n}s
`;
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);
$('#vgo').onclick = go; $('#vcx').onclick = () => { clearInterval(tm); box.innerHTML = ''; };
} catch { /* no next episode */ }
}
/** Stop playing: tell Plex where we got to and stop its conversion. Also runs whenever you leave the player. */
function videoStop() {
if (!vp) { document.body.classList.remove('vfullwin'); return; } const s = vp; vp = null; document.body.classList.remove('onvideo', 'vfullwin'); try { if (document.fullscreenElement) document.exitFullscreen(); } catch {} clearInterval(s.timer);
try { if (!s.done) tlSend('stopped', s.id, (s.base + (s.el ? s.el.currentTime : s.pos)) * 1000, s.m.duration || 0); } catch {}
try { if (s.hls) s.hls.destroy(); if (s.el) { s.el.pause(); s.el.removeAttribute('src'); s.el.load(); } } catch {}
fetch('/plex/video/:/transcode/universal/stop?session=' + s.session).catch(() => {});
const mb = document.getElementById('vmini'); if (mb) { mb.hidden = true; mb.querySelector('.vmslot').innerHTML = ''; }
try { renderMini(); } catch {} // the bar goes back to Music (or hides)
}
addEventListener('hashchange', () => { if (vp && !location.hash.startsWith('#/video/watch/')) videoMini(true); });
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 || !location.hash.startsWith('#/video/watch/') || /^(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(); if (e.target.tagName === 'BUTTON') addEventListener('keyup', ev => ev.preventDefault(), { once: true, capture: true }); el.paused ? el.play() : el.pause(); } // (a focused button would also click on key-up: that is the double toggle)
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);
// ---- Edit a title's details the way Plex's Edit dialog does: only what changed is saved, and each saved field is locked so a Plex refresh keeps it.
const VD_TYPE = { movie: 1, show: 2, season: 3, episode: 4 };
async function videoEdit(m) {
const kind = m.type, rich = kind === 'movie' || kind === 'show', genres = (m.Genre || []).map(g => g.tag);
const F = [['title', 'Title'], ['titleSort', 'Sort title'], ['originalTitle', 'Original title'], ['originallyAvailableAt', kind === 'show' ? 'First aired' : 'Release date', 'date'], ['contentRating', 'Content rating'], ...(rich ? [['studio', 'Studio'], ['tagline', 'Tagline']] : []), ['summary', 'Summary', 'area']]
.filter(([k]) => kind !== 'season' || ['title', 'titleSort', 'summary'].includes(k));
const old = k => String(m[k] || ''), dateOf = v => String(v || '').slice(0, 10);
const el = openSheet('Edit ' + (m.title || ''), '', `