317 lines
33 KiB
JavaScript
317 lines
33 KiB
JavaScript
'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 = [`<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();
|
||
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">Loading…</p></div>';
|
||
try {
|
||
const st = await jfetch('/api/plex/status');
|
||
if (!location.hash.startsWith('#/video')) return;
|
||
if (!st.signedIn || !st.server) { v.innerHTML = `<div class="wrap"><h1>Movies & TV</h1><p class="sub">Movies and TV shows come from your Plex server. Sign in to Plex (and choose the server) in the Music tab first; it is the same sign-in.</p><div class="bar"><a class="btn" href="#/music">Open Music</a></div></div>`; return; }
|
||
px.serverKey = st.server;
|
||
if (kind === 'watch') return videoWatch(key);
|
||
if (kind === 'item') return videoItem(key);
|
||
if (kind === 'lib') return videoHome(st, key);
|
||
return videoHome(st);
|
||
} catch (e) { v.innerHTML = `<div class="wrap"><h1>Movies & TV</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="vretry">Try again</button></div></div>`; $('#vretry').onclick = () => route(); }
|
||
}
|
||
|
||
// ---- Home: Continue Watching, then a library at a time
|
||
const vdCard = (m, wide) => {
|
||
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 `<a class="vcard${wide ? ' wide' : ''}" 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>
|
||
<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>`;
|
||
};
|
||
async function videoHome(st, libKey) {
|
||
const v = $('#view');
|
||
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
|
||
if (!secs.length) { v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">This Plex server has no movie or TV libraries (or your Plex user cannot see them).</p></div>'; return; }
|
||
const cur = secs.find(s => s.key === (libKey || store.get('video_lib'))) || secs[0];
|
||
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>
|
||
<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>`;
|
||
// 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 = `<h2>Continue watching</h2><div class="vrow">${items.map(m => vdCard(m, true)).join('')}</div>`;
|
||
}).catch(() => {});
|
||
const type = cur.type === 'movie' ? 1 : 2, size = 120; let start = 0, total = 0, all = [];
|
||
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);
|
||
$('#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>` : '';
|
||
if ($('#vmb')) $('#vmb').onclick = () => load().catch(e => toast(e.message));
|
||
if (!all.length) $('#vgrid').innerHTML = '<p class="empty">Nothing in this library.</p>';
|
||
};
|
||
$('#vgrid').innerHTML = ''; load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; });
|
||
let t; $('#vq').oninput = e => {
|
||
clearTimeout(t); const q = e.target.value.trim(); if (q.length < 2) { $('#vsr').innerHTML = ''; return; }
|
||
t = setTimeout(async () => {
|
||
try {
|
||
const mc = await px.get(`/hubs/search?query=${vdEnc(q)}&limit=10`), rows = [];
|
||
for (const h of mc.Hub || []) if (['movie', 'show', 'episode'].includes(h.type)) for (const m of h.Metadata || []) rows.push(m);
|
||
if (!$('#vsr') || $('#vq').value.trim() !== q) return;
|
||
$('#vsr').innerHTML = rows.length ? `<h2>Results</h2><div class="vgrid">${rows.map(m => vdCard(m)).join('')}</div>` : '<p class="empty">Nothing found.</p>';
|
||
} catch (err) { if ($('#vsr')) $('#vsr').innerHTML = `<p class="err">${esc(err.message)}</p>`; }
|
||
}, 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 = '<div class="wrap"><p class="empty">That item is gone.</p></div>'; return; }
|
||
const back = m.type === 'season' ? `#/video/item/${m.parentRatingKey}` : m.type === 'episode' ? `#/video/item/${m.parentRatingKey || m.grandparentRatingKey}` : '#/video';
|
||
const names = (arr, k = 'tag', n = 8) => (arr || []).slice(0, n).map(x => esc(x[k])).join(', ');
|
||
const media = (m.Media || [])[0] || {}, res = media.videoResolution ? (/^\d+$/.test(media.videoResolution) ? media.videoResolution + 'p' : media.videoResolution.toUpperCase()) : '';
|
||
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">${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>
|
||
<div class="bar" id="vact" style="margin:10px 0">${playable ? `<button class="btn" id="vplay">${icon('play')}${m.viewOffset ? 'Resume · ' + vdClock(m.viewOffset / 1000) : 'Play'}</button>${m.viewOffset ? '<button class="btn ghost" id="vstart">From the start</button>' : ''}`
|
||
: m.type === 'show' && next ? `<button class="btn" id="vnext">${icon('play')}${next.viewOffset ? 'Resume' : 'Play'} ${vdEp(next).replace(/^.*· /, '')}</button>` : ''}
|
||
${m.type !== 'season' ? `<button class="btn ghost" id="vseen">${m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount) ? 'Mark unwatched' : 'Mark watched'}</button>` : ''}</div></div></div></div>
|
||
<p class="vsum">${esc(m.summary || '')}</p>
|
||
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||
<div id="vkids"></div></div>`;
|
||
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
||
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
||
if ($('#vseen')) $('#vseen').onclick = async () => {
|
||
const seen = m.viewCount > 0 || (m.leafCount && m.viewedLeafCount === m.leafCount);
|
||
try { await px.get(`/:/${seen ? 'unscrobble' : 'scrobble'}?key=${id}&identifier=com.plexapp.plugins.library`).catch(() => {}); toast(seen ? 'Marked unwatched' : 'Marked watched'); route(); } catch (e) { toast(e.message); }
|
||
};
|
||
if (m.type === 'show' && !next) px.get(`/library/metadata/${id}/allLeaves?X-Plex-Container-Start=0&X-Plex-Container-Size=1`).then(mc => { // nothing started yet: offer the first episode
|
||
const e = (mc.Metadata || [])[0]; if (!e || !$('#vact')) return;
|
||
$('#vact').insertAdjacentHTML('afterbegin', `<button class="btn" id="vfirst">${icon('play')}Play S${e.parentIndex}E${e.index}</button>`); $('#vfirst').onclick = () => { location.hash = `#/video/watch/${e.ratingKey}`; };
|
||
}).catch(() => {});
|
||
if (m.type === 'show' || m.type === 'season') {
|
||
const kids = (await px.get(`/library/metadata/${id}/children`)).Metadata || [];
|
||
if (!$('#vkids')) return;
|
||
$('#vkids').innerHTML = m.type === 'show' ? `<h2>Seasons</h2><div class="vgrid">${kids.map(k => vdCard(k)).join('')}</div>`
|
||
: `<h2>Episodes</h2><div class="list">${kids.map(e => `<a class="row vep" href="#/video/item/${e.ratingKey}"><span class="vimg" style="aspect-ratio:16/9;width:120px;flex:none">${e.thumb ? `<img loading="lazy" alt="" src="${vdArt(e.thumb, 240, 135)}" onerror="this.remove()">` : `<i>${icon('film')}</i>`}${e.viewCount > 0 && !e.viewOffset ? `<em class="vseen">${icon('check')}</em>` : ''}${vdPct(e) ? `<i class="vprog"><b style="width:${vdPct(e)}%"></b></i>` : ''}</span><span class="grow"><b>${e.index}. ${esc(e.title)}</b><small style="white-space:normal">${esc(vdTime(e.duration))}${e.summary ? ' · ' + esc(e.summary.slice(0, 110)) + (e.summary.length > 110 ? '…' : '') : ''}</small></span></a>`).join('')}</div>`;
|
||
}
|
||
}
|
||
|
||
// ---- The player
|
||
let vp = null; // what is playing now
|
||
const canDirect = media => {
|
||
const c = (media.container || '').toLowerCase(), vc = (media.videoCodec || '').toLowerCase(), ac = (media.audioCodec || '').toLowerCase();
|
||
return ['mp4', 'm4v', 'mov'].includes(c) && vc === 'h264' && ['aac', 'mp3'].includes(ac);
|
||
};
|
||
const QUALITIES = [['auto', 'Auto', { kbps: 20000 }], ['original', 'Original (no conversion)', null], ['1080', '1080p · 10 Mbps', { kbps: 10000, res: '1920x1080' }], ['720', '720p · 4 Mbps', { kbps: 4000, res: '1280x720' }], ['480', '480p · 1.5 Mbps', { kbps: 1500, res: '854x480' }]];
|
||
let hlsLib = null;
|
||
const loadHls = () => hlsLib || (hlsLib = new Promise((ok, no) => { if (window.Hls) return ok(window.Hls); const s = document.createElement('script'); s.src = '/vendor/hls.min.js?v=1.7.3'; s.onload = () => ok(window.Hls); s.onerror = () => no(new Error('Could not load the video player library')); document.head.appendChild(s); }));
|
||
|
||
async function videoWatch(id) {
|
||
const v = $('#view'), mc = await px.get(`/library/metadata/${id}`), 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; }
|
||
videoStop();
|
||
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, 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, 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;
|
||
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);
|
||
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('.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; }
|
||
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'; });
|
||
/** 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; 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;
|
||
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…';
|
||
if (el.canPlayType('application/vnd.apple.mpegurl')) { el.src = url; }
|
||
else { try { const Hls = await loadHls(); if (!vp || vp.el !== el) return; if (!Hls.isSupported()) throw new Error('This browser cannot play converted video'); const h = vp.hls = new Hls({ maxBufferLength: 40, enableWorker: true }); h.loadSource(url); h.attachMedia(el);
|
||
h.on(Hls.Events.ERROR, (e, d) => { if (d.fatal) { if (d.type === Hls.ErrorTypes.NETWORK_ERROR) { const tt = $('#vmsg'); if (tt) tt.textContent = 'Connection problem. Retrying…'; h.startLoad(); } else if (d.type === Hls.ErrorTypes.MEDIA_ERROR) h.recoverMediaError(); else { const tt = $('#vmsg'); if (tt) tt.textContent = 'Could not play this. Try a lower quality in Quality & tracks.'; } } }); }
|
||
catch (e) { const tt = $('#vmsg'); if (tt) tt.textContent = e.message; return; } }
|
||
}
|
||
el.play().catch(() => {});
|
||
}
|
||
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', `
|
||
<label for="vq2">Quality</label><select id="vq2">${QUALITIES.map(([k, l]) => `<option value="${k}" ${k === vp.q ? 'selected' : ''}>${l}</option>`).join('')}</select>
|
||
${aud.length > 1 ? `<label for="va2">Audio</label><select id="va2"><option value="0">Default</option>${aud.map(s => `<option value="${s.id}" ${String(s.id) === String(vp.audio) ? 'selected' : ''}>${esc(s.displayTitle || s.language || 'Track ' + s.id)}</option>`).join('')}</select>` : ''}
|
||
${sub.length ? `<label for="vs2">Subtitles</label><select id="vs2"><option value="0">Off</option>${sub.map(s => `<option value="${s.id}" ${String(s.id) === String(vp.sub) ? 'selected' : ''}>${esc(s.displayTitle || s.language || 'Track ' + s.id)}</option>`).join('')}</select><p class="sub" style="margin:4px 0 0">Subtitles are drawn into the picture by Plex, so choosing one converts the video.</p>` : ''}
|
||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="vapply" style="flex:1;justify-content:center">Apply</button></div>`);
|
||
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 = `<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);
|
||
$('#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(() => {});
|
||
}
|
||
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.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' || 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);
|