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:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 11:00:20 -04:00
1 parent f3216b2f3b
commit 2b79026157
3 files changed
+55 -12

No files matched your search

+17 -7
View File
@@ -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; }
};
}
+13
View File
@@ -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
View File
@@ -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);