Movies & TV: the video keeps playing in a mini player when you leave the player page, and comes back to the full player without restarting
build-container / build (push) Successful in 22m14s
build-container / build (push) Successful in 22m14s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f232dc32d3
commit
0f324bbf60
3 files changed
+94
-35
No files matched your search
@@ -47,7 +47,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser.
|
||||
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
|
||||
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
|
||||
- **Movies & TV** (Plex): a tab under Media for your Plex movie and TV libraries, using the same Plex sign-in and Plex Home user as Music (sign in once in the Music tab). Continue Watching, a poster grid per library with Show more, search, a page for each movie, show, season and episode (summary, cast, genres, runtime), Mark watched/unwatched, and Resume or From the start. The player plays the file straight when the browser can (MP4 with H.264 and AAC); otherwise Plex converts it as it plays (HLS: native in Safari, with the bundled hls.js in `public/vendor/` elsewhere), with a Quality choice (Auto, Original, 1080p, 720p, 480p), an audio-track choice and subtitles (drawn into the picture by Plex, so choosing one converts the video). It reports your position to Plex every 10 seconds (so Continue Watching and the Plex dashboard follow), marks an item watched at the end, offers the next episode with a 10-second countdown, stops the conversion when you leave, and pauses Music while a video plays. Controls: a seek bar with elapsed and remaining time, play/pause, back 10 s and forward 10 s, previous and next episode, volume, speed (0.75 to 2×), Quality & tracks, picture in picture and full screen, which hide after 3 seconds (click plays/pauses and double click goes full screen; on a touch screen tap shows the controls and double tap on the left or right side skips 10 s). Keys: space or k play/pause, left/right or j/l skip (shift or j/l = 30 s), up/down volume, 0-9 jump to 0-90%, n and p next and previous episode, f full screen, m mute. **Mini player**: leave the player page (to any tab) and the video keeps playing in a small window at the bottom right, with play/pause, back to the full player and close; music and video pause each other. Every page shows where you are (library, show, season) with links back, and a pill for each library. Give it to people in Settings -> People (Movies & TV). Tried only against a fake Plex with a real test video, in a Safari-engine browser and through hls.js; how it behaves with your real files (conversion speed, subtitles, odd codecs) is the thing to try. Not included: downloads, casting (Chromecast/AirPlay), skip intro, text subtitles you can switch without converting.
|
||||
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
|
||||
Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
|
||||
the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
|
||||
|
||||
@@ -653,3 +653,15 @@ body.nav-mini .ngrp span { display: none; }
|
||||
.vnav .sgrp { margin-left: auto; }
|
||||
.vtop .vcrumbs { color: rgba(255, 255, 255, .8); } .vtop .vcrumbs a { color: rgba(255, 255, 255, .8); }
|
||||
@media (max-width: 700px) { .vtop .vtc { display: none; } .vnav .sgrp { margin-left: 0; } }
|
||||
|
||||
/* Video mini player */
|
||||
#vmini { position: fixed; right: 14px; bottom: calc(var(--nav-h) + var(--sab) + 76px); width: 300px; max-width: calc(100vw - 28px); z-index: 45; border-radius: 12px; overflow: hidden; background: #000; box-shadow: 0 8px 28px rgba(0, 0, 0, .45); border: 1px solid var(--line); }
|
||||
@media (min-width: 768px) { #vmini { bottom: 84px; } }
|
||||
#vmini[hidden] { display: none; }
|
||||
.vmslot { cursor: pointer; } .vmslot video { width: 100%; display: block; aspect-ratio: 16 / 9; background: #000; }
|
||||
.vmover { position: absolute; left: 0; right: 0; top: 0; padding: 6px 8px; display: flex; align-items: center; gap: 8px; color: #fff; background: linear-gradient(rgba(0, 0, 0, .75), transparent); opacity: 0; transition: opacity .2s; }
|
||||
#vmini:hover .vmover, #vmini:focus-within .vmover { opacity: 1; }
|
||||
@media (hover: none) { .vmover { opacity: 1; } }
|
||||
.vmtitle { flex: 1; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.vmbtns { display: flex; gap: 2px; } .vmbtns button { border: 0; background: rgba(255, 255, 255, .15); color: #fff; width: 32px; height: 32px; border-radius: 8px; display: grid; place-items: center; cursor: pointer; } .vmbtns button svg { width: 18px; height: 18px; }
|
||||
.vmprog { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgba(255, 255, 255, .25); display: block; } .vmprog b { display: block; height: 100%; background: var(--accent); }
|
||||
+81
-34
@@ -29,7 +29,6 @@ async function vdNav(m, back) {
|
||||
}
|
||||
async function viewVideo(args = []) {
|
||||
const v = $('#view'), [kind, key] = args;
|
||||
if (kind !== 'watch') videoStop();
|
||||
v.innerHTML = '<div class="wrap"><h1>Movies & TV</h1><p class="empty">Loading…</p></div>';
|
||||
try {
|
||||
const st = await jfetch('/api/plex/status');
|
||||
@@ -139,37 +138,45 @@ const QUALITIES = [['auto', 'Auto', { kbps: 20000 }], ['original', 'Original (no
|
||||
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}`), 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 && vp.el && !vp.el.paused) vp.el.pause(); }); } // one thing at a time: music starting pauses 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');
|
||||
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, sibs: null, speed: 1 };
|
||||
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.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(() => {});
|
||||
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 title = m.type === 'episode' ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index} · ${m.title}` : m.title, ep = m.type === 'episode';
|
||||
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"><video id="vv" playsinline autoplay></video>
|
||||
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>
|
||||
<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 class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', '1×', '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')}</div></div></div>
|
||||
<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')}</div></div></div>
|
||||
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||||
const el = $('#vv'), stage = $('#vstage'); vp.el = el;
|
||||
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
|
||||
el.volume = Math.max(0, Math.min(1, +store.get('video_vol', 1))); $('#vvol').value = Math.round(el.volume * 100);
|
||||
const total = () => (vp.m.duration || 0) / 1000, here = () => vp.base + el.currentTime;
|
||||
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); };
|
||||
const paint = () => {
|
||||
const t = total() || el.duration || 0, p = here();
|
||||
if (!drag) { $('#vrange').value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; }
|
||||
$('#vcur').textContent = vdClock(drag ? $('#vrange').value / 1000 * t : p); $('#vrem').textContent = '-' + vdClock(Math.max(0, t - (drag ? $('#vrange').value / 1000 * t : p)));
|
||||
};
|
||||
const flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
|
||||
vp.flash = flash; vp.wake = wake;
|
||||
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);
|
||||
@@ -179,9 +186,9 @@ async function videoWatch(id) {
|
||||
$('#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; paint(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
|
||||
// clicks: mouse click plays or pauses, double click goes full screen; touch: a tap shows or hides the controls, a double tap on the left or right side skips 10 s
|
||||
let lastTap = 0;
|
||||
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')) return;
|
||||
if (e.pointerType === 'touch') {
|
||||
@@ -190,20 +197,59 @@ async function videoWatch(id) {
|
||||
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(); }
|
||||
});
|
||||
let lastDbl = 0; stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
||||
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
|
||||
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; paint(); });
|
||||
el.addEventListener('play', () => { $('#vpp').innerHTML = icon('pause'); $('#vpp').setAttribute('aria-label', 'Pause'); wake(); });
|
||||
el.addEventListener('pause', () => { $('#vpp').innerHTML = icon('play'); $('#vpp').setAttribute('aria-label', 'Play'); wake(); if (!el.ended) vdReport('paused'); });
|
||||
el.addEventListener('volumechange', () => { $('#vmute').innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
|
||||
el.addEventListener('playing', () => { msg(''); $('#vspin').hidden = true; vdReport('playing'); });
|
||||
el.addEventListener('waiting', () => { $('#vspin').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', () => { $('#vspin').hidden = true; if (vp) vp.waitSince = 0; });
|
||||
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 && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); });
|
||||
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.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.'); });
|
||||
vp.timer = setInterval(() => { if (vp && vp.el === el && !el.paused && !el.ended) vdReport('playing'); }, 10000);
|
||||
if (ep) 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); const pv = $('#vprev'), nx = $('#vnextep'); if (pv) pv.disabled = vp.sibIdx <= 0; if (nx) nx.disabled = vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1; } }).catch(() => {});
|
||||
paint(); videoStart(vp.pos);
|
||||
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 mp = $('#vmpp'); if (mp) mp.innerHTML = icon(el.paused ? 'play' : 'pause'); const pr = $('#vmprog b'); if (pr) pr.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%';
|
||||
}
|
||||
/** 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 mini player');
|
||||
box.innerHTML = `<div class="vmslot"></div><div class="vmover"><b class="vmtitle"></b><div class="vmbtns"><button id="vmpp" aria-label="Play or pause"></button><button id="vmx" aria-label="Back to the player">${icon('full')}</button><button id="vmc" aria-label="Close">${icon('x')}</button></div></div><i class="vmprog" id="vmprog"><b></b></i>`;
|
||||
document.body.appendChild(box);
|
||||
box.querySelector('#vmpp').onclick = e => { e.stopPropagation(); const el = vp && vp.el; if (el) el.paused ? el.play().catch(() => {}) : el.pause(); };
|
||||
box.querySelector('#vmc').onclick = e => { e.stopPropagation(); videoStop(); };
|
||||
const open = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||
box.querySelector('#vmx').onclick = e => { e.stopPropagation(); open(); }; box.querySelector('.vmslot').onclick = open;
|
||||
}
|
||||
box.hidden = false; box.querySelector('.vmtitle').textContent = vdTitle(vp.m);
|
||||
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();
|
||||
}
|
||||
/** 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) {
|
||||
@@ -226,7 +272,7 @@ function videoGo(dir) {
|
||||
/** 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 (!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; }
|
||||
@@ -298,11 +344,12 @@ function videoStop() {
|
||||
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 = ''; }
|
||||
}
|
||||
addEventListener('hashchange', () => { if (vp && !location.hash.startsWith('#/video/watch/')) videoStop(); });
|
||||
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 || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName || '') && e.target.type !== 'range' || e.metaKey || e.ctrlKey || e.altKey || document.getElementById('sheet')) return;
|
||||
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(); el.paused ? el.play() : el.pause(); }
|
||||
|
||||
Reference in new issue
Block a user