From ee4674cdaddbe461fda10087b91ea607dac80770 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:04:40 -0400 Subject: [PATCH] Video: controls move from the video box to the bottom bar, in full screen too Co-Authored-By: Claude Sonnet 5.5 --- public/style.css | 8 ++++--- public/video.js | 54 +++++++++++++++++++++--------------------------- 2 files changed, 28 insertions(+), 34 deletions(-) diff --git a/public/style.css b/public/style.css index c25dd0d..5398bbb 100644 --- a/public/style.css +++ b/public/style.css @@ -659,8 +659,9 @@ body.nav-mini .ngrp span { display: none; } .vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } } #vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); } #vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; } -.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; } -.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; } +.vstage.vfull { position: fixed; inset: 0 0 var(--playerh, 0px) 0; z-index: 90; border-radius: 0; } +body.vfullwin #player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 91; } +.vstage.vfull video, .vstage:fullscreen video { height: calc(100dvh - var(--playerh, 0px)); max-height: none; object-fit: contain; } .vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; } .vstage:fullscreen video { height: 100%; } @media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } } @@ -705,7 +706,6 @@ body.nav-mini .ngrp span { display: none; } .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; } @@ -1149,3 +1149,5 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text- .lyrqrow .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } .lyrqrow.now .num { color: var(--accent); } .lyrqrow .grow { min-width: 0; } .lyrqrow b, .lyrqrow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrqrow small { color: var(--muted); font-size: 12px; } #lyr .lyrbody:has(.lyrq) { padding-top: 10vh; padding-bottom: 16vh; } #lyr .lyrbody:has(.lyrq) .empty { padding-top: 0; text-align: center; } + +.mini .vbvol { width: 84px; accent-color: var(--accent); } .mini .ibtn.txt { width: auto; min-width: 40px; font-size: 13px; font-weight: 600; padding: 0 6px; } diff --git a/public/video.js b/public/video.js index b04110e..485540c 100644 --- a/public/video.js +++ b/public/video.js @@ -310,34 +310,18 @@ function videoMount() { const b = (idn, ic, label, extra = '') => ``; v.innerHTML = `
${icon('back')}Back${esc(title)} -
${b('vmute', icon('vol'), 'Mute')}${b('vspd', vp.speed + '×', 'Speed', ' txt')}${castOk(vp.el) ? b('vcast', 'Cast', 'Play on a TV or speaker', ' 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')}
+
-
0:00-0:00
-
${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `${icon('rew')}10`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `${icon('fwd')}10`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}

`; 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); }; 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); - if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el); - $('#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'); - 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, .vmark')) return; + if (e.target.closest('.vtop, #vnextbox, .vmark')) return; if (e.pointerType === 'touch') { const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width; if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; } @@ -414,11 +398,21 @@ function videoBar() { el.innerHTML = `
${ep ? `` : ''}${ep ? `` : ''} + + ${castOk(vp.el) ? `` : ''} + ${document.pictureInPictureEnabled ? `` : ''}
`; 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); + const el0 = vp.el; + q('#vmute').onclick = () => { el0.muted = !el0.muted; }; + q('#vvol').oninput = e => { el0.volume = e.target.value / 100; el0.muted = el0.volume === 0; store.set('video_vol', el0.volume); }; + q('#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; el0.playbackRate = r; q('#vspd').textContent = r + '×'; } }))); + if (q('#vcast')) q('#vcast').onclick = () => castTo(vp && vp.el); + q('#vset').onclick = videoSettings; q('#vfs').onclick = videoFullscreen; + if (q('#vpip')) q('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el0.requestPictureInPicture(); } catch {} }; 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; }; // drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter) @@ -467,19 +461,17 @@ 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 (!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; } - 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(); } + if (!vp) return; const box = document.querySelector('.vstage'); if (!box) return; + if (box.classList.contains('vfull')) return videoFsExit(); + box.classList.add('vfull'); document.body.classList.add('vfullwin'); // (the picture fills the window; the bottom bar stays under it with the controls) + const root = document.documentElement, req = root.requestFullscreen || root.webkitRequestFullscreen; + if (req) { try { const p = req.call(root); if (p && p.catch) p.catch(() => {}); } catch {} } // (if the browser refuses, the full-window mode above is what you get) } -document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; }); +function videoFsExit() { + const box = document.querySelector('.vstage.vfull'); if (box) box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); + if (document.fullscreenElement || document.webkitFullscreenElement) { try { (document.exitFullscreen || document.webkitExitFullscreen).call(document); } catch {} } +} +document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && document.body.classList.contains('vfullwin')) videoFsExit(); }); /** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */ function videoRescue(why, at) { if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false; @@ -568,7 +560,7 @@ addEventListener('keydown', e => { else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); } else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); } else if (k === 'f') { e.preventDefault(); videoFullscreen(); } - else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } } + else if (k === 'Escape') videoFsExit(); else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1); else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); } }, true);