Movies & TV: own full-screen button with a full-window fallback, first-episode button for unstarted shows; Plex sign-in survives plex.tv taking over the tab
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3216b2f3b
commit
2b79026157
3 files changed
+55
-12
No files matched your search
+17
-7
@@ -52,22 +52,32 @@ async function viewMusic(args) {
|
||||
}
|
||||
}
|
||||
|
||||
// The sign-in on plex.tv may take over this very tab (installed apps, some browsers) and then come back, so the pending sign-in is remembered
|
||||
// and picked up again when this page loads.
|
||||
const PLEX_PIN = 'plex_pin';
|
||||
async function plexWaitPin(pin, w) {
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
if (!location.hash.startsWith('#/music') && !location.hash.startsWith('#/video')) return;
|
||||
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
|
||||
if (r.ok) { try { localStorage.removeItem(PLEX_PIN); } catch {} try { w && w.close(); } catch {} return route(); }
|
||||
}
|
||||
try { localStorage.removeItem(PLEX_PIN); } catch {}
|
||||
const e = $('#pe'); if (e) e.textContent = 'Timed out waiting for Plex. Try again.';
|
||||
}
|
||||
function plexSignIn(msg = '') {
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Music</h1><p class="sub">Play your Plex music library here.</p>
|
||||
<div class="card"><p style="margin-top:0">You'll sign in on plex.tv in a new tab. This app never sees your Plex password.</p>
|
||||
<div class="bar"><button class="btn" id="pl">Sign in with Plex</button></div><div class="err" id="pe">${esc(msg)}</div></div></div>`;
|
||||
let pend = null; try { pend = JSON.parse(localStorage.getItem(PLEX_PIN)); } catch {}
|
||||
if (pend && Date.now() - pend.t < 10 * 60e3) { $('#pe').textContent = 'Finishing your Plex sign-in…'; plexWaitPin(pend, null).catch(e => { const x = $('#pe'); if (x) x.textContent = e.message; }); }
|
||||
$('#pl').onclick = async () => {
|
||||
const w = window.open('', '_blank'); // opened synchronously so popup blockers allow it
|
||||
try {
|
||||
const pin = await jfetch('/api/plex/pin', { method: 'POST' });
|
||||
try { localStorage.setItem(PLEX_PIN, JSON.stringify({ id: pin.id, code: pin.code, t: Date.now() })); } catch {}
|
||||
if (w) w.location = pin.authUrl; else $('#pe').innerHTML = `<a href="${esc(pin.authUrl)}" target="_blank" rel="noopener" style="color:var(--accent)">Tap to open Plex sign-in</a>`;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
if (!location.hash.startsWith('#/music')) return;
|
||||
const r = await jfetch(`/api/plex/pin?id=${pin.id}&code=${encodeURIComponent(pin.code)}`);
|
||||
if (r.ok) { try { w?.close(); } catch {} return route(); }
|
||||
}
|
||||
$('#pe').textContent = 'Timed out waiting for Plex. Try again.';
|
||||
await plexWaitPin(pin, w);
|
||||
} catch (e) { $('#pe').textContent = e.message; }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -605,3 +605,16 @@ body.nav-mini .ngrp span { display: none; }
|
||||
.vnext { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 10px; padding: 12px 14px; border-radius: 12px; background: var(--panel2); }
|
||||
.vnext b { flex: 1; min-width: 160px; }
|
||||
@media (min-width: 768px) { .vgrid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } .vpost { width: 190px; } }
|
||||
|
||||
.vrow { scrollbar-width: thin; }
|
||||
.vrow::-webkit-scrollbar { height: 6px; } .vrow::-webkit-scrollbar-thumb { background: var(--line); border-radius: 3px; }
|
||||
|
||||
.vwrap.vfull { position: fixed; inset: 0; z-index: 90; max-width: none; background: #000; display: flex; flex-direction: column; padding: calc(var(--sat) + 6px) 8px calc(var(--sab) + 6px); }
|
||||
.vwrap.vfull video { flex: 1; min-height: 0; max-height: none; border-radius: 0; }
|
||||
.vwrap.vfull .vbar { position: absolute; top: calc(var(--sat) + 8px); left: 8px; right: 8px; z-index: 2; opacity: 0; transition: opacity .2s; }
|
||||
.vwrap.vfull:hover .vbar, .vwrap.vfull .vbar:focus-within { opacity: 1; }
|
||||
.vwrap:fullscreen { background: #000; max-width: none; display: flex; flex-direction: column; padding: 6px 8px; }
|
||||
.vwrap:fullscreen video { flex: 1; min-height: 0; max-height: none; border-radius: 0; }
|
||||
.vwrap:fullscreen .vbar { position: absolute; top: 8px; left: 8px; right: 8px; z-index: 2; opacity: 0; transition: opacity .2s; }
|
||||
.vwrap:fullscreen:hover .vbar, .vwrap:fullscreen .vbar:focus-within { opacity: 1; }
|
||||
@media (hover: none) { .vwrap.vfull .vbar, .vwrap:fullscreen .vbar { opacity: 1; } }
|
||||
+25
-5
@@ -99,6 +99,10 @@ async function videoItem(id) {
|
||||
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;
|
||||
@@ -127,11 +131,11 @@ async function videoWatch(id) {
|
||||
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 };
|
||||
document.body.classList.add('onvideo');
|
||||
const title = m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title;
|
||||
v.innerHTML = `<div class="vwrap"><div class="vbar"><a class="btn ghost" id="vback" href="${m.type === 'episode' ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><b class="vtitle">${esc(title)}</b><button class="btn ghost" id="vset">Quality & tracks</button></div>
|
||||
<video id="vv" controls playsinline autoplay></video><p class="sub" id="vmsg" role="status"></p><div id="vnextbox"></div></div>`;
|
||||
v.innerHTML = `<div class="vwrap"><div class="vbar"><a class="btn ghost" id="vback" href="${m.type === 'episode' ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><b class="vtitle">${esc(title)}</b><button class="btn ghost" id="vfs" aria-label="Full screen">Full screen</button><button class="btn ghost" id="vset">Quality & tracks</button></div>
|
||||
<video id="vv" controls controlsList="nofullscreen" playsinline autoplay></video><p class="sub" id="vmsg" role="status"></p><div id="vnextbox"></div></div>`;
|
||||
const el = $('#vv'); vp.el = el;
|
||||
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
|
||||
$('#vset').onclick = videoSettings;
|
||||
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
||||
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) { vp.pos = vp.base + el.currentTime; } });
|
||||
el.addEventListener('playing', () => { msg(''); vdReport('playing'); });
|
||||
el.addEventListener('pause', () => { if (!el.ended) vdReport('paused'); });
|
||||
@@ -142,6 +146,22 @@ async function videoWatch(id) {
|
||||
vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000);
|
||||
videoStart(vp.pos);
|
||||
}
|
||||
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
|
||||
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
||||
function videoFullscreen() {
|
||||
if (!vp) return; const box = document.querySelector('.vwrap'), el = vp.el;
|
||||
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;
|
||||
@@ -197,7 +217,7 @@ async function videoEnded() {
|
||||
}
|
||||
/** Stop playing: tell Plex where we got to and stop its conversion. Also runs whenever you leave the player. */
|
||||
function videoStop() {
|
||||
if (!vp) return; const s = vp; vp = null; document.body.classList.remove('onvideo'); clearInterval(s.timer);
|
||||
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(() => {});
|
||||
@@ -209,5 +229,5 @@ addEventListener('keydown', e => {
|
||||
const el = vp.el, k = e.key;
|
||||
if (k === ' ' || k === 'k') { e.preventDefault(); el.paused ? el.play() : el.pause(); }
|
||||
else if (k === 'ArrowRight') { e.preventDefault(); el.currentTime += 10; } else if (k === 'ArrowLeft') { e.preventDefault(); el.currentTime = Math.max(0, el.currentTime - 10); }
|
||||
else if (k === 'f') { e.preventDefault(); el.requestFullscreen && (document.fullscreenElement ? document.exitFullscreen() : el.requestFullscreen()); } else if (k === 'm') el.muted = !el.muted;
|
||||
else if (k === 'f') { e.preventDefault(); videoFullscreen(); } else if (k === 'Escape') { const b = document.querySelector('.vwrap.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } } else if (k === 'm') el.muted = !el.muted;
|
||||
}, true);
|
||||
Reference in new issue
Block a user