From 2b79026157006eb639f0af8e4fa68885ac825142 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:00:20 -0400 Subject: [PATCH] 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 --- public/music.js | 24 +++++++++++++++++------- public/style.css | 13 +++++++++++++ public/video.js | 30 +++++++++++++++++++++++++----- 3 files changed, 55 insertions(+), 12 deletions(-) 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.

${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', ``); $('#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 = `
${icon('back')}Back${esc(title)}
-

`; + v.innerHTML = `
${icon('back')}Back${esc(title)}
+

`; 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);