diff --git a/frontend/app.js b/frontend/app.js index 83ec757..fa9ff1f 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -11456,7 +11456,7 @@ document.querySelectorAll('.chip').forEach((c) => { stage: els.video.parentElement, video: els.video, settings: () => data.settings, persist, toggle: () => Player.toggle(), seek: time => Player.seek(time), - state: () => ({ time: Player.master.currentTime, duration: Player.master.duration, paused: Player.master.paused }), + state: () => ({ time: Player.master.currentTime, duration: Player.master.duration, paused: Player.master.paused, title: current?.meta?.title || '' }), }); window.playerFullscreen = fullscreen; els.fsBtn.addEventListener('click', () => fullscreen.enter()); diff --git a/frontend/fullscreen-orientation.js b/frontend/fullscreen-orientation.js index 79c7a50..a363caa 100644 --- a/frontend/fullscreen-orientation.js +++ b/frontend/fullscreen-orientation.js @@ -11,15 +11,23 @@ if (angle === -90) return { x: rect.bottom - y, y: x - rect.left, width: rect.height, height: rect.width }; return { x: x - rect.left, y: y - rect.top, width: rect.width, height: rect.height }; } + function normalizeFit(value) { return value === 'fit' ? 'fit' : 'fill'; } function create(options) { const { stage, settings, persist, toggle, seek, state } = options; let active = false, custom = false, native = false, locked = false, angle = 0, timer, hideTimer; const ancestors = []; for (let el = stage.parentElement; el && el !== document.body; el = el.parentElement) ancestors.push(el); const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true; - hud.innerHTML = ''; + hud.innerHTML = '
'; stage.append(hud); - const [play, orientation, exit] = hud.querySelectorAll('button'), range = hud.querySelector('input'); + const play = hud.querySelector('button[aria-label="Play or pause"]'), orientation = hud.querySelector('.fullscreen-orientation'), fitButton = hud.querySelector('.fullscreen-fit'), exit = hud.querySelector('button[aria-label="Exit fullscreen"]'), range = hud.querySelector('input'); + const title = hud.querySelector('.fullscreen-title'); + function setFit(value) { + const fit = normalizeFit(value); settings().fullscreenFit = fit; persist(); + stage.dataset.fullscreenFit = fit; + fitButton.setAttribute('aria-label', `Video fill mode: ${fit === 'fill' ? 'Fill' : 'Fit'}`); + fitButton.title = `${fit === 'fill' ? 'Fill' : 'Fit'} video frame`; + } function revealControls() { if (!active) return; hud.classList.remove('hud-hidden'); clearTimeout(hideTimer); @@ -33,10 +41,24 @@ hud.addEventListener('focusout', () => setTimeout(revealControls, 0)); hud.addEventListener('click', revealControls); play.onclick = toggle; exit.onclick = leave; + fitButton.onclick = () => setFit(settings().fullscreenFit === 'fill' ? 'fit' : 'fill'); + let lastTap = 0, pointers = new Set(); + stage.addEventListener('pointerdown', event => { + if (!active || hud.contains(event.target)) return; + pointers.add(event.pointerId); + if (pointers.size > 1) { setFit(settings().fullscreenFit === 'fill' ? 'fit' : 'fill'); pointers.clear(); return; } + const now = performance.now(), rect = stage.getBoundingClientRect(); + const center = Math.abs(event.clientX - rect.left - rect.width / 2) < rect.width * .24 && Math.abs(event.clientY - rect.top - rect.height / 2) < rect.height * .24; + if (center && now - lastTap < 330) { setFit(settings().fullscreenFit === 'fill' ? 'fit' : 'fill'); lastTap = 0; } + else lastTap = center ? now : 0; + }, { passive: true }); + stage.addEventListener('pointerup', event => pointers.delete(event.pointerId), { passive: true }); + stage.addEventListener('pointercancel', event => pointers.delete(event.pointerId), { passive: true }); function layout() { if (!active) return; angle = custom ? rotation(settings().fullscreenOrientation, innerWidth, innerHeight) : 0; stage.dataset.fullscreenRotation = String(angle); + title.textContent = state().title || ''; orientation.setAttribute('aria-label', `Change fullscreen orientation (currently ${normalize(settings().fullscreenOrientation)})`); orientation.title = `Orientation: ${normalize(settings().fullscreenOrientation)}`; } @@ -65,6 +87,7 @@ ancestors.forEach(el => el.classList.remove('orientation-ancestor')); hud.hidden = true; hud.classList.remove('hud-hidden'); stage.classList.remove('orientation-overlay', 'orientation-fullscreen'); delete stage.dataset.fullscreenRotation; document.body.classList.remove('landscape-fs'); + delete stage.dataset.fullscreenFit; if (locked) { screen.orientation?.unlock?.(); locked = false; } } function leave() { @@ -74,6 +97,7 @@ async function enter() { if (active) { leave(); return; } active = true; ancestors.forEach(el => el.classList.add('orientation-ancestor')); stage.classList.add('orientation-fullscreen'); hud.hidden = false; revealControls(); + setFit(normalizeFit(settings().fullscreenFit)); document.body.classList.add('landscape-fs'); try { if (!stage.requestFullscreen) throw new Error('Custom fullscreen'); @@ -86,6 +110,7 @@ timer = setInterval(() => { const s = state(); range.value = String(s.duration ? s.time / s.duration * 1000 : 0); + range.style.setProperty('--seek-progress', `${s.duration ? Math.min(100, s.time / s.duration * 100) : 0}%`); play.setAttribute('aria-label', s.paused ? 'Play video' : 'Pause video'); play.title = s.paused ? 'Play' : 'Pause'; play.innerHTML = s.paused @@ -98,6 +123,6 @@ window.addEventListener('resize', layout); return { enter, leave, point: event => coordinates(event.clientX, event.clientY, stage.getBoundingClientRect(), angle) }; } - const api = { normalize, rotation, coordinates, create }; + const api = { normalize, normalizeFit, rotation, coordinates, create }; if (typeof module !== 'undefined' && module.exports) module.exports = api; else root.FullscreenOrientation = api; })(globalThis); diff --git a/frontend/fullscreen-orientation.test.js b/frontend/fullscreen-orientation.test.js index 8acd391..76c6dc0 100644 --- a/frontend/fullscreen-orientation.test.js +++ b/frontend/fullscreen-orientation.test.js @@ -1,6 +1,11 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); -const { normalize, rotation, coordinates } = require('./fullscreen-orientation'); +const { normalize, normalizeFit, rotation, coordinates } = require('./fullscreen-orientation'); +test('fullscreen fit defaults safely to fill and accepts fit', () => { + assert.equal(normalizeFit(undefined), 'fill'); + assert.equal(normalizeFit('fit'), 'fit'); + assert.equal(normalizeFit('unexpected'), 'fill'); +}); test('orientation defaults and only rotates mismatched viewports', () => { assert.equal(normalize('invalid'), 'auto'); assert.equal(rotation('auto', 390, 844), 0); diff --git a/plans/codex-shots-r2/05-fullscreen-portrait-fill.png b/plans/codex-shots-r2/05-fullscreen-portrait-fill.png new file mode 100644 index 0000000..afb9f5d Binary files /dev/null and b/plans/codex-shots-r2/05-fullscreen-portrait-fill.png differ diff --git a/plans/codex-shots-r2/06-fullscreen-portrait-fit.png b/plans/codex-shots-r2/06-fullscreen-portrait-fit.png new file mode 100644 index 0000000..dba66a3 Binary files /dev/null and b/plans/codex-shots-r2/06-fullscreen-portrait-fit.png differ diff --git a/plans/codex-shots-r2/07-fullscreen-landscape-fill.png b/plans/codex-shots-r2/07-fullscreen-landscape-fill.png new file mode 100644 index 0000000..4010cf3 Binary files /dev/null and b/plans/codex-shots-r2/07-fullscreen-landscape-fill.png differ diff --git a/plans/codex-shots-r2/10-fullscreen-portrait-fill.png b/plans/codex-shots-r2/10-fullscreen-portrait-fill.png new file mode 100644 index 0000000..62a075a Binary files /dev/null and b/plans/codex-shots-r2/10-fullscreen-portrait-fill.png differ diff --git a/plans/codex-shots-r2/11-fullscreen-portrait-fit.png b/plans/codex-shots-r2/11-fullscreen-portrait-fit.png new file mode 100644 index 0000000..0997719 Binary files /dev/null and b/plans/codex-shots-r2/11-fullscreen-portrait-fit.png differ diff --git a/plans/codex-shots-r2/12-fullscreen-landscape-fill.png b/plans/codex-shots-r2/12-fullscreen-landscape-fill.png new file mode 100644 index 0000000..00e30a1 Binary files /dev/null and b/plans/codex-shots-r2/12-fullscreen-landscape-fill.png differ