build-container / build (push) Successful in 9m53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
506 lines
57 KiB
JavaScript
506 lines
57 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;
|
||
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 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);
|
||
const html = `<a class="vcard${wide ? ' wide' : ''}" data-rk="${m.ratingKey}" data-type="${m.type}" data-show="${m.grandparentRatingKey || ''}" data-seen="${seen ? 1 : 0}" 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) + (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(' · '))}</small></a>`;
|
||
return deck ? `<div class="vdeckc">${html}<button class="vx" data-rm="${m.ratingKey}" aria-label="Remove from Continue watching" title="Remove from Continue watching">${icon('x')}</button></div>` : 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');
|
||
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="vhero"></div><div id="vdeck"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><label class="vunw"><input type="checkbox" id="vunwc"> Unwatched only</label></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
||
// 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 ? `<h2>${title}${deck ? '<button class="btn ghost vclear" id="vclr" style="min-height:32px;margin-left:10px">Clear all</button>' : ''}</h2><div class="vrow">${items.map(m => vdCard(m, true, deck)).join('')}</div>` : ''; };
|
||
const drawDeck = items => { vd.deck = items; rowOf('#vdeck', 'Continue watching', items, true);
|
||
const h = items[0], hb = $('#vhero'); // the most recent one gets a banner with a Resume button
|
||
if (hb) hb.innerHTML = h ? `<a class="vban" href="#/video/watch/${h.ratingKey}" aria-label="Resume ${esc(h.title)}">${(h.art || h.thumb) ? `<img alt="" src="${vdArt(h.art || h.thumb, 900, 380)}" onerror="this.remove()">` : ''}<span class="vbant"><small>${vdLeft(h) ? 'Pick up where you left off · ' + vdLeft(h) + ' min left' : 'Up next'}</small><b>${esc(h.type === 'episode' ? h.grandparentTitle : h.title)}</b>${h.type === 'episode' ? `<small>${vdEp(h).replace(/^.*· /, '')} · ${esc(h.title)}</small>` : ''}<em class="btn">${icon('play')}Resume</em></span></a>` : ''; 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 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 = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
|
||
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 ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
|
||
}).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 => `<option value="${esc(d.key)}">${esc(d.title)}</option>`).join('')); }).catch(() => {});
|
||
let start = 0, total = 0, all = [], gen = 0;
|
||
const load = async () => {
|
||
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 .vsk') && $('#vgrid').querySelectorAll('.vsk').forEach(x => x.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 = `<div class="vempty">${icon('film')}<p>Nothing matches.</p>${$('#vunwc').checked || genre ? '<button class="btn ghost" id="vfc">Clear filters</button>' : ''}</div>`; if ($('#vfc')) $('#vfc').onclick = () => { $('#vunwc').checked = false; store.set('video_unw', '0'); genre = ''; $('#vgen').value = ''; reload(); }; }
|
||
};
|
||
const reload = () => { gen++; start = 0; total = 0; all = []; $('#vmore').innerHTML = ''; $('#vgrid').innerHTML = '<i class="vsk"></i>'.repeat(12); load().catch(e => { if ($('#vgrid')) $('#vgrid').innerHTML = `<p class="err">${esc(e.message)}</p>`; }); };
|
||
$('#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 () => {
|
||
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>`;
|
||
jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then
|
||
if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
|
||
}).catch(() => {});
|
||
const pimg = document.querySelector('.vpost img'); // tint the backdrop with the poster's own colour
|
||
if (pimg) { const tint = () => { try { const c = document.createElement('canvas'); c.width = c.height = 8; const x = c.getContext('2d'); x.drawImage(pimg, 0, 0, 8, 8); const d = x.getImageData(0, 0, 8, 8).data; let r = 0, g = 0, b = 0; for (let i = 0; i < d.length; i += 4) { r += d[i]; g += d[i + 1]; b += d[i + 2]; } const n = d.length / 4, h = document.querySelector('.vhero'); if (h) h.style.setProperty('--vtint', `rgb(${Math.round(r / n)}, ${Math.round(g / n)}, ${Math.round(b / n)})`); } catch {} }; pimg.complete && pimg.naturalWidth ? tint() : pimg.addEventListener('load', tint, { once: true }); }
|
||
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); }));
|
||
|
||
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}?includeMarkers=1`), 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 {}
|
||
if (!vdHooked) { vdHooked = true; pl.a.addEventListener('play', () => { if (vp) videoStop(); }); } // one thing at a time: music starting ends the video
|
||
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');
|
||
const el = document.createElement('video'); el.playsInline = true; el.autoplay = true; el.id = 'vv';
|
||
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();
|
||
}
|
||
const vdTitle = m => m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title;
|
||
/** Draw the player page and put the (persistent) video element into it. */
|
||
function videoMount() {
|
||
if (!vp) return; videoMini(false);
|
||
const v = $('#view'), m = vp.m, id = vp.id, ep = m.type === 'episode', title = vdTitle(m), el = vp.el;
|
||
document.body.classList.add('onvideo');
|
||
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">
|
||
<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 class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', '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')}${b('vclose', icon('x'), 'Close the video')}</div></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></div>
|
||
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||
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);
|
||
$('#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);
|
||
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', () => videoEnded());
|
||
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 = '<div class="vmslot" title="Open the player"></div>';
|
||
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 = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
|
||
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
|
||
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
|
||
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
|
||
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'; });
|
||
/** 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…';
|
||
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(() => {});
|
||
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 || ''), '', `<div class="sgrp" role="tablist" style="margin-bottom:8px"><a href="#" data-t="d" aria-selected="true" class="on">Details</a><a href="#" data-t="p" aria-selected="false">Poster</a><a href="#" data-t="a" aria-selected="false">Background</a></div>
|
||
<form id="vf"><div id="vtab-d">${F.map(([k, l, t]) => `<label for="ve-${k}">${l}</label>${t === 'area' ? `<textarea id="ve-${k}" rows="5">${esc(old(k))}</textarea>` : `<input type="${t || 'text'}" id="ve-${k}" value="${esc(t === 'date' ? dateOf(m[k]) : old(k))}" autocomplete="off">`}`).join('')}
|
||
${rich ? `<label for="ve-genre">Genres <small>(separate with commas)</small></label><input type="text" id="ve-genre" value="${esc(genres.join(', '))}" autocomplete="off">` : ''}
|
||
<p class="sub" style="margin:10px 0 0">Changed fields are locked in Plex, so scans and refreshes will not put the old values back.</p></div>
|
||
<div id="vtab-p" hidden></div><div id="vtab-a" hidden></div>
|
||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="vs" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ve"></div></form>`);
|
||
el.querySelector('.sheet').classList.add('wide');
|
||
el.querySelector('#sx').onclick = closeSheet;
|
||
const loaded = {};
|
||
const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link
|
||
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty">Loading…</p>';
|
||
try {
|
||
const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || [];
|
||
box.innerHTML = `<div class="vimgs ${what}">${list.map((x, i) => `<button type="button" class="vpick ${x.selected ? 'on' : ''}" data-i="${i}" aria-label="Use this image"><img alt="" loading="lazy" src="${/^https?:/.test(x.thumb || '') ? esc(x.thumb) : vdArt(x.thumb, 200, 300)}"></button>`).join('') || '<p class="empty">No images yet.</p>'}</div>
|
||
<label>Add from an image link</label><div class="bar" style="margin-top:4px"><input type="url" class="vurl" placeholder="https://…" autocomplete="off" style="flex:1"><button type="button" class="btn ghost vadd">Add</button></div><div class="err vie"></div>`;
|
||
const put = async (url, add) => {
|
||
const r = await fetch(`/plex/library/metadata/${m.ratingKey}/${add ? what : what === 'posters' ? 'poster' : 'art'}?url=${encodeURIComponent(url)}`, { method: add ? 'POST' : 'PUT' });
|
||
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change images' : 'Plex would not take that (' + r.status + ')');
|
||
};
|
||
box.querySelectorAll('.vpick').forEach(b => b.onclick = async () => { try { await put(list[+b.dataset.i].ratingKey || list[+b.dataset.i].key); box.querySelectorAll('.vpick').forEach(x => x.classList.toggle('on', x === b)); toast('Image changed'); changed = true; } catch (e) { box.querySelector('.vie').textContent = e.message; } });
|
||
box.querySelector('.vadd').onclick = async () => { const u = box.querySelector('.vurl').value.trim(); if (!/^https:\/\//.test(u)) { box.querySelector('.vie').textContent = 'Use a link that starts with https://'; return; } try { await put(u, true); toast('Image added'); changed = true; loaded[tab] = false; images(tab, what); } catch (e) { box.querySelector('.vie').textContent = e.message; } };
|
||
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||
};
|
||
let changed = false;
|
||
el.querySelectorAll('.sgrp a[data-t]').forEach(a => a.onclick = ev => {
|
||
ev.preventDefault(); const t = a.dataset.t;
|
||
el.querySelectorAll('.sgrp a[data-t]').forEach(x => { x.classList.toggle('on', x === a); x.setAttribute('aria-selected', x === a); });
|
||
['d', 'p', 'a'].forEach(k => { el.querySelector('#vtab-' + k).hidden = k !== t; }); el.querySelector('#vs').hidden = t !== 'd';
|
||
if (t !== 'd' && !loaded[t]) { loaded[t] = true; images(t, t === 'p' ? 'posters' : 'arts'); }
|
||
});
|
||
el.querySelector('#vf').onsubmit = async ev => {
|
||
ev.preventDefault(); const btn = el.querySelector('#vs'); btn.disabled = true; el.querySelector('#ve').textContent = '';
|
||
const q = new URLSearchParams({ type: VD_TYPE[kind] || 1, id: m.ratingKey, includeExternalMedia: 1 }); let n = 0;
|
||
for (const [k, , t] of F) { const v = el.querySelector('#ve-' + k).value.trim(); if (v !== (t === 'date' ? dateOf(m[k]) : old(k))) { q.set(k + '.value', v); q.set(k + '.locked', 1); n++; } }
|
||
if (rich) { const g = [...new Set(el.querySelector('#ve-genre').value.split(',').map(x => x.trim()).filter(Boolean))]; if (g.join('|') !== genres.join('|')) { g.forEach((x, i) => q.set(`genre[${i}].tag.tag`, x)); q.set('genre.locked', 1); n++; } }
|
||
if (!n) { closeSheet(); if (changed) route(); return; }
|
||
try {
|
||
const r = await fetch(`/plex/library/sections/${m.librarySectionID}/all?${q}`, { method: 'PUT' });
|
||
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change details' : 'Plex would not save that (' + r.status + ')');
|
||
closeSheet(); toast('Saved in Plex'); route();
|
||
} catch (e) { btn.disabled = false; el.querySelector('#ve').textContent = e.message; }
|
||
};
|
||
}
|