Movies & TV: the video controls live in the bottom bar like Music; the floating picture has no controls; tools along the top of the player page
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2bde697955
commit
79dcd5cdf3
4 files changed
+44
-26
No files matched your search
@@ -178,6 +178,7 @@ function markPlaying() {
|
||||
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
|
||||
}
|
||||
function renderMini() {
|
||||
if (typeof videoBarOn === 'function' && videoBarOn()) return; // the bottom bar is showing the video's controls
|
||||
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
|
||||
el.hidden = false;
|
||||
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
||||
|
||||
@@ -685,3 +685,11 @@ body.nav-mini .ngrp span { display: none; }
|
||||
.vmb:hover { background: var(--panel2); } .vmb:disabled { opacity: .35; cursor: default; } .vmb[hidden] { display: none; }
|
||||
.vmb svg { width: 20px; height: 20px; } .vmb.big { width: 40px; height: 40px; background: var(--accent); color: var(--accent-t); border-radius: 50%; } .vmb.big svg { width: 22px; height: 22px; }
|
||||
.vmb .vskip em { font-size: 8px; top: 6px; }
|
||||
|
||||
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
|
||||
.vstage:not(:fullscreen):not(.vfull) .vctl { display: none; }
|
||||
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
||||
.vtop .vtc { flex: 0 1 auto; }
|
||||
.mini .vskip { position: relative; display: grid; place-items: center; } .mini .vskip em { position: absolute; font-style: normal; font-size: 8px; font-weight: 700; top: 7px; }
|
||||
@media (max-width: 700px) { .vtop .vright input[type=range], .vtop #vmute, .vtop #vspd { display: none; } .vtop .vtitle { display: none; } }
|
||||
+34
-25
@@ -145,7 +145,7 @@ async function videoWatch(id) {
|
||||
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
|
||||
if (!vdHooked) { vdHooked = true; pl.a.addEventListener('play', () => { if (vp) videoStop(); }); } // one thing at a time: music starting ends 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');
|
||||
const el = document.createElement('video'); el.playsInline = true; el.autoplay = true; el.id = 'vv';
|
||||
@@ -154,7 +154,7 @@ async function videoWatch(id) {
|
||||
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();
|
||||
videoBar(); 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. */
|
||||
@@ -164,11 +164,11 @@ function videoMount() {
|
||||
document.body.classList.add('onvideo');
|
||||
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">
|
||||
<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="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 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')}${b('vclose', icon('x'), 'Close the video')}</div></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', 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 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></div>
|
||||
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||||
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
|
||||
$('#vvol').value = Math.round(el.volume * 100);
|
||||
@@ -183,6 +183,7 @@ function videoMount() {
|
||||
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
||||
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
|
||||
$('#vmute').onclick = () => { el.muted = !el.muted; };
|
||||
$('#vclose').onclick = () => { const back = $('#vback').getAttribute('href'); videoStop(); location.hash = back; };
|
||||
$('#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');
|
||||
@@ -220,9 +221,10 @@ function vdUi() {
|
||||
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'); mp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
|
||||
const mr = $('#vmrange'); if (mr) { const d = vp.miniDrag; if (!d) mr.value = t ? Math.round(1000 * Math.min(p, t) / t) : 0; const shown = d ? mr.value / 1000 * t : p; $('#vmcur').textContent = vdClock(shown); $('#vmrem').textContent = '-' + vdClock(Math.max(0, t - shown)); }
|
||||
const mpv = $('#vmprev'), mnx = $('#vmnext'); if (mpv) mpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (mnx) mnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
|
||||
const bp = $('#vbpp'); if (bp) { bp.innerHTML = icon(el.paused ? 'play' : 'pause'); bp.setAttribute('aria-label', el.paused ? 'Play' : 'Pause'); }
|
||||
const bi = $('#vbp'); if (bi) bi.style.width = (t ? Math.min(100, 100 * p / t) : 0) + '%';
|
||||
const bt = $('#vbtm'); if (bt) bt.textContent = vdClock(p) + ' / ' + vdClock(t);
|
||||
const bpv = $('#vbprev'), bnx = $('#vbnext'); if (bpv) bpv.disabled = !vp.sibs || vp.sibIdx <= 0; if (bnx) bnx.disabled = !vp.sibs || vp.sibIdx < 0 || vp.sibIdx >= vp.sibs.length - 1;
|
||||
}
|
||||
/** The mini player: the video keeps playing in a small window while you use other tabs. */
|
||||
function videoMini(on) {
|
||||
@@ -230,27 +232,33 @@ function videoMini(on) {
|
||||
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="vmctl">
|
||||
<div class="vmhead"><b class="vmtitle"></b><button class="vmb" id="vmx" aria-label="Back to the player" title="Back to the player">${icon('full')}</button><button class="vmb" id="vmc" aria-label="Close" title="Close">${icon('x')}</button></div>
|
||||
<input type="range" id="vmrange" min="0" max="1000" step="1" value="0" aria-label="Seek">
|
||||
<div class="vmrow"><span id="vmcur">0:00</span><div class="vmmid"><button class="vmb" id="vmprev" aria-label="Previous episode" title="Previous episode" hidden>${icon('prev')}</button><button class="vmb" id="vmb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="vmb big" id="vmpp" aria-label="Play or pause"></button><button class="vmb" id="vmf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button><button class="vmb" id="vmnext" aria-label="Next episode" title="Next episode" hidden>${icon('next')}</button></div><span id="vmrem">-0:00</span></div></div>`;
|
||||
document.body.appendChild(box);
|
||||
const q = s => box.querySelector(s), stop = f => e => { e.stopPropagation(); f(e); };
|
||||
q('#vmpp').onclick = stop(() => { const el = vp && vp.el; if (el) el.paused ? el.play().catch(() => {}) : el.pause(); });
|
||||
q('#vmc').onclick = stop(() => videoStop());
|
||||
const open = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||
q('#vmx').onclick = stop(open); q('.vmslot').onclick = open;
|
||||
q('#vmb10').onclick = stop(() => videoSkip(-10)); q('#vmf10').onclick = stop(() => videoSkip(10));
|
||||
q('#vmprev').onclick = stop(() => videoGo(-1)); q('#vmnext').onclick = stop(() => videoGo(1));
|
||||
const r = q('#vmrange'); r.oninput = () => { vp && (vp.miniDrag = true); vdUi(); }; r.onchange = () => { if (!vp) return; const t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0; videoSeek(r.value / 1000 * t); vp.miniDrag = false; };
|
||||
box = document.createElement('div'); box.id = 'vmini'; box.setAttribute('role', 'region'); box.setAttribute('aria-label', 'Video (click to open the player)');
|
||||
box.innerHTML = '<div class="vmslot" title="Open the player"></div>';
|
||||
document.body.appendChild(box); box.querySelector('.vmslot').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||
}
|
||||
box.hidden = false; box.querySelector('.vmtitle').textContent = vdTitle(vp.m);
|
||||
const ep = vp.m.type === 'episode'; box.querySelector('#vmprev').hidden = !ep; box.querySelector('#vmnext').hidden = !ep;
|
||||
vp.el.controls = false; box.querySelector('.vmslot').appendChild(vp.el); document.body.classList.remove('onvideo'); document.body.classList.remove('vfullwin');
|
||||
box.hidden = false; 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();
|
||||
}
|
||||
/** The bottom bar (the same one Music uses) shows the video's controls on every tab: poster, title, time, previous and next episode, back and forward 10 s, play/pause, expand, close. */
|
||||
const videoBarOn = () => !!(vp && vp.el);
|
||||
function videoBar() {
|
||||
const el = $('#player'); if (!el || !vp) return; const m = vp.m, ep = m.type === 'episode';
|
||||
el.hidden = false;
|
||||
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
|
||||
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
|
||||
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
|
||||
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
|
||||
const q = s => el.querySelector(s);
|
||||
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
|
||||
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
|
||||
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
|
||||
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
|
||||
const sk = q('#vbseek'), at = e => { if (!vp) return; const r = sk.getBoundingClientRect(), t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0; if (t) videoSeek(Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * t); };
|
||||
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
||||
vdUi();
|
||||
}
|
||||
/** Lock-screen and media-key controls. */
|
||||
function videoMediaSession() {
|
||||
if (!('mediaSession' in navigator) || !vp) return;
|
||||
@@ -355,6 +363,7 @@ function videoStop() {
|
||||
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 = ''; }
|
||||
try { renderMini(); } catch {} // the bar goes back to Music (or hides)
|
||||
}
|
||||
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'); } });
|
||||
|
||||
Reference in new issue
Block a user