diff --git a/frontend/app.js b/frontend/app.js index e67511d..c06cec9 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -6990,11 +6990,13 @@ const Gestures = (() => { showHud(`
${d < 0 ? '⟲' : '⟳'} ${Math.abs(d)} s
`); } + const point = e => window.playerFullscreen?.point(e) || FullscreenOrientation.coordinates(e.clientX, e.clientY, stage.getBoundingClientRect()); function onTap(e) { + const p = point(e); const r = stage.getBoundingClientRect(); - const x = (e.clientX - r.left) / r.width; + const x = point(e).x / point(e).width; const now = performance.now(); - if (now - st.lastTap < 320 && Math.abs(e.clientX - st.lastX) < 60) { + if (now - st.lastTap < 320 && Math.abs(p.x - st.lastX) < 60) { st.lastTap = 0; if (x < 1 / 3) seekBy(-10, 'left'); else if (x > 2 / 3) seekBy(10, 'right'); @@ -7003,12 +7005,12 @@ const Gestures = (() => { return; } st.lastTap = now; - st.lastX = e.clientX; + st.lastX = p.x; } stage.addEventListener('pointerdown', (e) => { if (e.pointerType !== 'touch') return; - pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); + pts.set(e.pointerId, { x: point(e).x, y: point(e).y }); st.moved = false; if (pts.size === 2) { const [a, b] = [...pts.values()]; @@ -7016,23 +7018,23 @@ const Gestures = (() => { st.swipe = null; } else if (pts.size === 1 && isFs()) { const r = stage.getBoundingClientRect(); - const right = (e.clientX - r.left) / r.width > 0.5; - st.swipe = { y0: e.clientY, right, v0: data.settings.volume ?? 1, b0: st.bright, h: r.height, active: false }; + const right = point(e).x / point(e).width > 0.5; + st.swipe = { y0: point(e).y, right, v0: data.settings.volume ?? 1, b0: st.bright, h: point(e).height, active: false }; } }); stage.addEventListener('pointermove', (e) => { if (!pts.has(e.pointerId)) return; const p = pts.get(e.pointerId); - if (Math.hypot(e.clientX - p.x, e.clientY - p.y) > 10) st.moved = true; - pts.set(e.pointerId, { ...p, cx: e.clientX, cy: e.clientY }); + if (Math.hypot(point(e).x - p.x, point(e).y - p.y) > 10) st.moved = true; + pts.set(e.pointerId, { ...p, cx: point(e).x, cy: point(e).y }); if (st.pinch && pts.size >= 2) { const [a, b] = [...pts.values()].map((q) => ({ x: q.cx ?? q.x, y: q.cy ?? q.y })); const d = Math.hypot(a.x - b.x, a.y - b.y); st.zoom = Math.max(1, Math.min(3, st.pinch.zoom * (d / (st.pinch.d || 1)))); const cx = (a.x + b.x) / 2, cy = (a.y + b.y) / 2; const r = stage.getBoundingClientRect(); - const maxX = (r.width * (st.zoom - 1)) / 2, maxY = (r.height * (st.zoom - 1)) / 2; + const maxX = (point(e).width * (st.zoom - 1)) / 2, maxY = (point(e).height * (st.zoom - 1)) / 2; st.panX = Math.max(-maxX, Math.min(maxX, st.pinch.panX + (cx - st.pinch.cx))); st.panY = Math.max(-maxY, Math.min(maxY, st.pinch.panY + (cy - st.pinch.cy))); applyView(); @@ -7042,7 +7044,7 @@ const Gestures = (() => { } const sw = st.swipe; if (!sw) return; - const dy = sw.y0 - e.clientY; + const dy = sw.y0 - point(e).y; if (!sw.active && Math.abs(dy) < 16) return; sw.active = true; const delta = dy / (sw.h * 0.8); @@ -9802,6 +9804,13 @@ async function renderSettings() { ]; const groups = [...wrap.querySelectorAll(':scope > .set-group')]; definitions.forEach(([id, title, cluster, summary, icon], i) => SettingsSections.register({ id, title, cluster, summary, icon, render(container) { container.append(groups[i]); } })); + SettingsSections.register({ id: 'video-fullscreen', title: 'Video & fullscreen', cluster: 'Listening', icon: '', summary: () => `Fullscreen: ${FullscreenOrientation.normalize(data.settings.fullscreenOrientation)}`, render(container) { + const row = document.createElement('label'); row.className = 'set-row'; + row.innerHTML = 'Fullscreen orientation'; + const select = row.querySelector('select'); select.value = FullscreenOrientation.normalize(data.settings.fullscreenOrientation); + select.onchange = () => { data.settings.fullscreenOrientation = select.value; persist(); }; + container.append(row); + } }); SettingsSections.mount(wrap, { settings: data.settings, data, persist, toast }); // ---- Wire appearance / accessibility ---- @@ -11424,47 +11433,14 @@ document.querySelectorAll('.chip').forEach((c) => { els.loopBtn.addEventListener('click', toggleLoopOne); els.repeatBtn.addEventListener('click', toggleRepeat); if ($('fsMini')) $('fsMini').addEventListener('click', () => els.fsBtn.click()); - els.fsBtn.addEventListener('click', () => { - const stage = els.video.parentElement; - const video = els.video; - // iOS Safari — including an installed PWA running in standalone mode — - // does not implement the standard Fullscreen API for arbitrary elements. - // stage.requestFullscreen is simply undefined there, so the button did - // nothing (this is the iPhone-in-portrait-PWA bug report). WebKit instead - // exposes a video-only, non-standard fullscreen API that *does* work in - // standalone mode: HTMLVideoElement.webkitEnterFullscreen/ExitFullscreen. - // Try the standard API first everywhere else, then fall back to the - // WebKit video API before giving up. - if (document.fullscreenElement || video.webkitDisplayingFullscreen) { - try { - if (document.exitFullscreen) document.exitFullscreen()?.catch(() => {}); - else if (video.webkitExitFullscreen) video.webkitExitFullscreen(); - } catch { /* e.g. InvalidStateError — nothing more we can do */ } - return; - } - if (stage.requestFullscreen) { - // requestFullscreen() can both throw synchronously (e.g. - // InvalidStateError when preconditions like active user-gesture - // transient activation aren't met) and return a promise that rejects - // asynchronously. Guard against both instead of letting either surface - // as an uncaught error. - try { - stage.requestFullscreen()?.catch(() => {}); - } catch { /* no-op — fullscreen simply won't engage this time */ } - } else if (video.webkitEnterFullscreen) { - if (Player.mode === 'audio') { - toast('Fullscreen isn’t available in audio-only mode'); - return; - } - try { - // Throws InvalidStateError if the element has no loaded media (e.g. - // no video source yet) — nothing to show fullscreen in that case. - video.webkitEnterFullscreen(); - } catch { /* no-op — no media loaded to go fullscreen with */ } - } else { - toast('Fullscreen isn’t supported on this device'); - } + const fullscreen = FullscreenOrientation.create({ + 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 }), }); + window.playerFullscreen = fullscreen; + els.fsBtn.addEventListener('click', () => fullscreen.enter()); els.seek.addEventListener('input', () => { const dur = Player.master.duration || 0; diff --git a/frontend/fullscreen-orientation.css b/frontend/fullscreen-orientation.css new file mode 100644 index 0000000..7213dec --- /dev/null +++ b/frontend/fullscreen-orientation.css @@ -0,0 +1,18 @@ +/* Shared fullscreen surface; inline player styling is unchanged. */ +.orientation-fullscreen .fullscreen-hud[hidden] { display: none; } +.orientation-fullscreen .fullscreen-hud { position: absolute; z-index: 20; bottom: max(12px, env(safe-area-inset-bottom)); left: max(12px, env(safe-area-inset-left)); right: max(12px, env(safe-area-inset-right)); display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: 12px; background: rgb(0 0 0 / .8); color: white; } +.fullscreen-hud button { min-height: 44px; padding: 8px 12px; border: 1px solid rgb(255 255 255 / .25); border-radius: 8px; background: rgb(255 255 255 / .12); color: white; font: inherit; } +.fullscreen-hud button:focus-visible { outline: 2px solid white; outline-offset: 2px; } +.fullscreen-hud input { min-width: 40px; flex: 1; height: 44px; } +.player-stage.orientation-fullscreen { background: black; } +.player-stage.orientation-overlay { position: fixed !important; z-index: 10000 !important; inset: 0 !important; width: 100dvw !important; height: 100dvh !important; max-width: none !important; max-height: none !important; aspect-ratio: auto !important; margin: 0 !important; border-radius: 0 !important; } +.player-stage.orientation-overlay[data-fullscreen-rotation="90"], .player-stage.orientation-overlay[data-fullscreen-rotation="-90"] { inset: auto !important; left: 50% !important; top: 50% !important; width: 100dvh !important; height: 100dvw !important; transform: translate(-50%, -50%) rotate(90deg) !important; } +.player-stage.orientation-overlay[data-fullscreen-rotation="-90"] { transform: translate(-50%, -50%) rotate(-90deg) !important; } +.orientation-fullscreen video { width: 100% !important; height: 100% !important; object-fit: contain; } +.orientation-fullscreen #stgOverlay { display: none !important; } +.orientation-overlay[data-fullscreen-rotation="90"] .fullscreen-hud { bottom: max(12px, env(safe-area-inset-left)); left: max(12px, env(safe-area-inset-top)); right: max(12px, env(safe-area-inset-bottom)); } +.orientation-overlay[data-fullscreen-rotation="-90"] .fullscreen-hud { bottom: max(12px, env(safe-area-inset-right)); left: max(12px, env(safe-area-inset-bottom)); right: max(12px, env(safe-area-inset-top)); } +@media(max-width: 460px) { .fullscreen-hud { flex-wrap: wrap; } .fullscreen-hud input { flex-basis: 100%; order: -1; } .fullscreen-hud button { flex: 1; padding-inline: 6px; font-size: 12px; } } +/* Raise the existing ancestry instead of moving a playing video in WebKit. */ +body.landscape-fs .orientation-ancestor { z-index: 10001 !important; transform: none !important; filter: none !important; overflow: visible !important; } +body.landscape-fs { overflow: hidden; } diff --git a/frontend/fullscreen-orientation.js b/frontend/fullscreen-orientation.js new file mode 100644 index 0000000..05f8d5a --- /dev/null +++ b/frontend/fullscreen-orientation.js @@ -0,0 +1,82 @@ +(function (root) { + 'use strict'; + const choices = ['auto', 'landscape', 'portrait']; + const normalize = value => choices.includes(value) ? value : 'auto'; + function rotation(value, width, height) { + value = normalize(value); + return value === 'landscape' && width < height ? 90 : value === 'portrait' && width > height ? -90 : 0; + } + function coordinates(x, y, rect, angle = 0) { + if (angle === 90) return { x: y - rect.top, y: rect.right - x, width: rect.height, height: rect.width }; + 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 create(options) { + const { stage, settings, persist, toggle, seek, state } = options; + let active = false, custom = false, native = false, locked = false, angle = 0, timer; + 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 = ''; + stage.append(hud); + const [play, orientation, exit] = hud.querySelectorAll('button'), range = hud.querySelector('input'); + play.onclick = toggle; exit.onclick = leave; + function layout() { + if (!active) return; + angle = custom ? rotation(settings().fullscreenOrientation, innerWidth, innerHeight) : 0; + stage.dataset.fullscreenRotation = String(angle); + orientation.textContent = `Orientation: ${normalize(settings().fullscreenOrientation)}`; + } + async function orient() { + if (locked) { screen.orientation?.unlock?.(); locked = false; } + const value = normalize(settings().fullscreenOrientation); + if (!custom && value !== 'auto') { + try { await screen.orientation.lock(value); locked = true; if (!active) { screen.orientation.unlock(); locked = false; return; } } + catch { if (!active) return; custom = true; stage.classList.add('orientation-overlay'); } + } + layout(); + } + orientation.onclick = () => { settings().fullscreenOrientation = choices[(choices.indexOf(normalize(settings().fullscreenOrientation)) + 1) % choices.length]; persist(); orient(); }; + range.oninput = () => seek(Number(range.value) / 1000 * (state().duration || 0)); + // Explicit inverse coordinates also work for rotated range controls on WebKit. + const rangeSeek = event => { + const point = coordinates(event.clientX, event.clientY, range.getBoundingClientRect(), angle); + seek(Math.max(0, Math.min(1, point.x / point.width)) * (state().duration || 0)); + }; + range.onpointerdown = event => { event.preventDefault(); range.setPointerCapture(event.pointerId); rangeSeek(event); }; + range.onpointermove = event => { if (range.hasPointerCapture(event.pointerId)) rangeSeek(event); }; + hud.addEventListener('pointerdown', event => event.stopPropagation()); + hud.addEventListener('pointerup', event => event.stopPropagation()); + function cleanup() { + active = false; custom = false; native = false; angle = 0; clearInterval(timer); + ancestors.forEach(el => el.classList.remove('orientation-ancestor')); + hud.hidden = true; stage.classList.remove('orientation-overlay', 'orientation-fullscreen'); + delete stage.dataset.fullscreenRotation; document.body.classList.remove('landscape-fs'); + if (locked) { screen.orientation?.unlock?.(); locked = false; } + } + function leave() { + if (document.fullscreenElement === stage) document.exitFullscreen().catch(() => {}); + cleanup(); + } + 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; + document.body.classList.add('landscape-fs'); + try { + if (!stage.requestFullscreen) throw new Error('Custom fullscreen'); + await stage.requestFullscreen(); + native = true; + } catch { custom = true; stage.classList.add('orientation-overlay'); } + if (!active) return; + await orient(); + if (!active) return; + timer = setInterval(() => { const s = state(); range.value = String(s.duration ? s.time / s.duration * 1000 : 0); play.textContent = s.paused ? 'Play' : 'Pause'; }, 250); + } + document.addEventListener('fullscreenchange', () => { if (active && !document.fullscreenElement && native) cleanup(); }); + document.addEventListener('keydown', event => { if (active && event.key === 'Escape') leave(); }); + window.addEventListener('resize', layout); + return { enter, leave, point: event => coordinates(event.clientX, event.clientY, stage.getBoundingClientRect(), angle) }; + } + const api = { normalize, 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 new file mode 100644 index 0000000..8acd391 --- /dev/null +++ b/frontend/fullscreen-orientation.test.js @@ -0,0 +1,17 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { normalize, rotation, coordinates } = require('./fullscreen-orientation'); +test('orientation defaults and only rotates mismatched viewports', () => { + assert.equal(normalize('invalid'), 'auto'); + assert.equal(rotation('auto', 390, 844), 0); + assert.equal(rotation('landscape', 390, 844), 90); + assert.equal(rotation('landscape', 844, 390), 0); + assert.equal(rotation('portrait', 844, 390), -90); +}); +test('rotated touch coordinates preserve local seek and swipe axes', () => { + const rect = { left: 10, top: 20, right: 400, bottom: 864, width: 390, height: 844 }; + assert.deepEqual(coordinates(400, 20, rect, 90), { x: 0, y: 0, width: 844, height: 390 }); + assert.deepEqual(coordinates(10, 864, rect, 90), { x: 844, y: 390, width: 844, height: 390 }); + assert.deepEqual(coordinates(10, 864, rect, -90), { x: 0, y: 0, width: 844, height: 390 }); + assert.deepEqual(coordinates(205, 442, rect), { x: 195, y: 422, width: 390, height: 844 }); +}); diff --git a/frontend/index.html b/frontend/index.html index bdfdad1..8816933 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -35,6 +35,7 @@ +
@@ -661,6 +662,7 @@ + diff --git a/frontend/sw.js b/frontend/sw.js index 6c63f65..c23ae7c 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -91,6 +91,8 @@ const SHELL = [ '/p2p-transfer.js', '/p2p-recv-worker.js', '/settings-sections.js', + '/fullscreen-orientation.js', + '/fullscreen-orientation.css', '/download-errors.js', '/downloads.js', '/downloads-page.js', diff --git a/playwright.fullscreen.config.js b/playwright.fullscreen.config.js new file mode 100644 index 0000000..68e3651 --- /dev/null +++ b/playwright.fullscreen.config.js @@ -0,0 +1,2 @@ +const { defineConfig } = require('@playwright/test'); +module.exports = defineConfig({ testDir: './tests', testMatch: /fullscreen-orientation\.spec\.js/, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8097', serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8097 --no-clipboard', port: 8097 } }); diff --git a/tests/fullscreen-orientation.spec.js b/tests/fullscreen-orientation.spec.js new file mode 100644 index 0000000..5b646b4 --- /dev/null +++ b/tests/fullscreen-orientation.spec.js @@ -0,0 +1,56 @@ +const { test, expect } = require('@playwright/test'); +const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture'); +for (const layout of ['classic', 'glass-stage']) test(`${layout}: portrait forced landscape controls and restore`, async ({ page }, testInfo) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openClassic(page, { populated: true }); await showPlayingFixture(page); + await page.evaluate(layout => { + data.settings.layout = layout; data.settings.fullscreenOrientation = 'landscape'; applyAppearance(); + els.video.parentElement.requestFullscreen = undefined; + }, layout); + await page.locator('#fsBtn').click(); + const stage = page.locator('.orientation-overlay'); + await expect(stage).toHaveAttribute('data-fullscreen-rotation', '90'); + await expect.poll(async () => Math.round((await stage.boundingBox()).height)).toBe(844); + const rect = await stage.boundingBox(); + expect(Math.abs(rect.width - 390)).toBeLessThan(2); expect(Math.abs(rect.height - 844)).toBeLessThan(2); + for (const button of await stage.locator('.fullscreen-hud button').all()) { + const r = await button.boundingBox(); expect(r.x).toBeGreaterThanOrEqual(0); expect(r.y).toBeGreaterThanOrEqual(0); expect(r.x + r.width).toBeLessThanOrEqual(391); expect(r.y + r.height).toBeLessThanOrEqual(845); + } + await page.screenshot({ path: testInfo.outputPath(`${layout}-landscape.png`) }); + await stage.getByRole('button', { name: 'Change fullscreen orientation' }).click(); + await expect(stage).toHaveAttribute('data-fullscreen-rotation', '0'); + await stage.getByRole('button', { name: 'Exit fullscreen' }).click(); + await expect(page.locator('.orientation-overlay')).toHaveCount(0); + await expect(page.locator('.fullscreen-hud')).toBeHidden(); +}); +test('standard fullscreen requests the selected orientation and releases it', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openClassic(page, { populated: true }); await showPlayingFixture(page); + await page.evaluate(() => { + data.settings.fullscreenOrientation = 'landscape'; + window.orientationCalls = []; + const stage = els.video.parentElement; + stage.requestFullscreen = async () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: stage }); }; + document.exitFullscreen = async () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null }); }; + Object.defineProperty(screen, 'orientation', { configurable: true, value: { lock: async value => orientationCalls.push(value), unlock: () => orientationCalls.push('unlock') } }); + }); + await page.locator('#fsBtn').click(); + await expect.poll(() => page.evaluate(() => orientationCalls)).toEqual(['landscape']); + await page.locator('.fullscreen-hud').getByRole('button', { name: 'Exit fullscreen' }).click(); + expect(await page.evaluate(() => orientationCalls)).toEqual(['landscape', 'unlock']); +}); +test('browser fullscreen exit restores a CSS fallback after orientation lock rejects', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openClassic(page, { populated: true }); await showPlayingFixture(page); + await page.evaluate(() => { + data.settings.fullscreenOrientation = 'landscape'; + const stage = els.video.parentElement; + stage.requestFullscreen = async () => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: stage }); }; + Object.defineProperty(screen, 'orientation', { configurable: true, value: { lock: async () => { throw new Error('Unsupported'); } } }); + }); + await page.locator('#fsBtn').click(); + await expect(page.locator('.orientation-overlay')).toHaveAttribute('data-fullscreen-rotation', '90'); + await page.evaluate(() => { Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null }); document.dispatchEvent(new Event('fullscreenchange')); }); + await expect(page.locator('.orientation-overlay')).toHaveCount(0); + await expect(page.locator('.fullscreen-hud')).toBeHidden(); +});