diff --git a/public/music.js b/public/music.js
index 595ef8f..fb55ee3 100644
--- a/public/music.js
+++ b/public/music.js
@@ -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 = `
Music Play your Plex music library here.
You'll sign in on plex.tv in a new tab. This app never sees your Plex password.
Sign in with Plex
${esc(msg)}
`;
+ 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 = `Tap to open Plex sign-in `;
- 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; }
};
}
diff --git a/public/style.css b/public/style.css
index 7227f98..02721ae 100644
--- a/public/style.css
+++ b/public/style.css
@@ -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; } }
diff --git a/public/video.js b/public/video.js
index 402263a..ec19cc2 100644
--- a/public/video.js
+++ b/public/video.js
@@ -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', `${icon('play')}Play S${e.parentIndex}E${e.index} `); $('#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 = ``;
+ v.innerHTML = ``;
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);