Add selectable fullscreen orientation and an iPhone landscape overlay

This commit is contained in:
Jonathan Sykes
2026-10-04 02:19:44 +08:00
parent d43cce441b
commit 536555e12d
8 changed files with 205 additions and 50 deletions

View File

@@ -6990,11 +6990,13 @@ const Gestures = (() => {
showHud(`<div class="gh-seek ${side}">${d < 0 ? '⟲' : '⟳'} ${Math.abs(d)} s</div>`);
}
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: '<path d="M4 9V4h5m6 0h5v5M4 15v5h5m6 0h5v-5"/>', summary: () => `Fullscreen: ${FullscreenOrientation.normalize(data.settings.fullscreenOrientation)}`, render(container) {
const row = document.createElement('label'); row.className = 'set-row';
row.innerHTML = '<span>Fullscreen orientation</span><select id="setFullscreenOrientation"><option value="auto">Auto (follow device)</option><option value="landscape">Landscape</option><option value="portrait">Portrait</option></select>';
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;

View File

@@ -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; }

View File

@@ -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 = '<button type="button" aria-label="Play or pause">Play / pause</button><input type="range" min="0" max="1000" value="0" aria-label="Seek video"><button type="button" class="fullscreen-orientation" aria-label="Change fullscreen orientation"></button><button type="button" aria-label="Exit fullscreen">Exit</button>';
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);

View File

@@ -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 });
});

View File

@@ -35,6 +35,7 @@
<link rel="stylesheet" href="saved-page.css" />
<link rel="stylesheet" href="theme-glass-controls.css" />
<link rel="stylesheet" href="theme-glass-panel.css" />
<link rel="stylesheet" href="fullscreen-orientation.css" />
</head>
<body>
<div class="app">
@@ -661,6 +662,7 @@
<script src="p2p-transfer.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="fullscreen-orientation.js"></script>
<script src="settings-sections.js"></script>
<script src="direct-protocol.js"></script>
<script src="direct-stream.js"></script>

View File

@@ -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',