Compare commits

...

2 Commits

Author SHA1 Message Date
Jonathan Sykes
df4acfc9f1 Keep iPhone audio playing through background handoffs 2026-10-04 02:29:10 +08:00
Jonathan Sykes
536555e12d Add selectable fullscreen orientation and an iPhone landscape overlay 2026-10-04 02:26:54 +08:00
13 changed files with 458 additions and 83 deletions

View File

@@ -0,0 +1,88 @@
# Fullscreen orientation and iPhone audio continuity
## Task A — done
Settings → Listening → Video & fullscreen offers Auto (follow device), Landscape,
and Portrait. The choice persists on this device and can be cycled from the
fullscreen controls. Supported browsers request fullscreen and then lock the
selected orientation; Auto leaves orientation unlocked. Exit releases our lock.
When orientation locking or element fullscreen is unavailable, the player uses a
viewport overlay. A portrait viewport with Landscape selected rotates the existing
stage and its controls 90 degrees; Portrait in a landscape viewport rotates it
in the opposite direction. Safe-area padding follows the rotated axes. Touch
seeking, double-tap seeking, volume/brightness swipes and pinch movement use
inverse coordinates. The video stays in its existing DOM position to avoid
interrupting WebKit playback. Controls and browser fullscreen exit restore the
inline surface, including when a native orientation lock was rejected.
Safari's custom overlay fills the available dynamic viewport; it cannot force
Safari to hide browser chrome or change the phone's physical orientation. An
installed PWA has no browser toolbar occupying that viewport.
Files: `frontend/fullscreen-orientation.js`, `.css`, `.test.js`, small hooks in
`app.js`, `index.html`, `sw.js`, `tests/fullscreen-orientation.spec.js` and
`playwright.fullscreen.config.js`.
## Task B — done; physical iPhone verification remains
The previous progressive handoff loaded the audio source and sought it only when
the page was hidden, then paused the video before transferring master ownership.
That required a cold audio decoder start and allowed pause-handler re-entry.
Foreground recovery also sought the video, whose shared seeking handler then
sought the still-playing audio. Repeated play/playing/focus/watchdog handlers
could independently issue the same audio play/seek requests.
On iPhone, progressive playback now uses the existing dual playback path from the
start: a muted video and an audible audio element. Saved files use the same local
source; streams prefer their audio-only URL, falling back to the muxed source.
EQ-render playback already used this path and remains unchanged. Locking promotes
the already-playing audio to master without loading, seeking or restarting it.
Unlocking aligns only the silent video; its internal seek does not propagate to
the audible audio. Pending play requests are shared, small drift does not cause
seeks, and the watchdog never seeks an element to itself. Hidden focus events do
not restore the video prematurely. The audio clock still owns background progress
and ended events, preserving auto-advance and lock-screen playback intent.
This uses an audio decoder alongside video while visible. Audio-only playback
and tracks that start while already backgrounded retain their existing paths.
Browsers other than iPhone retain their foreground progressive path. Their cold
handoff fallback now sets master ownership before media events and waits for audio
to play before pausing video; returning keeps that audio playing while video resumes.
Files: `frontend/audio-continuity.js`, `.test.js`, player/event hooks in `app.js`,
`index.html`, `sw.js`, `tests/audio-continuity.spec.js`, and
`playwright.audio-continuity.config.js`.
## Validation and hardware review
- 132 frontend unit tests pass, including orientation mapping, source selection,
small-drift policy, concurrent play deduplication, autoplay rejection retry, and
stale pending requests after pause/source reset.
- `node --check frontend/app.js` and `bun build --no-bundle server/server.js` pass.
- Eight fullscreen browser cases pass in Chromium/WebKit at 390 × 844, covering
Classic and Glass Stage overlays, visible controls, quick toggle, exit/restore,
lock/unlock calls and browser exit after lock rejection.
- Four audio browser cases use real local PCM media in Chromium/WebKit, with an
iPhone user agent, covering saved and streamed progressive attachments. Repeated
visibility/pagehide/focus/pageshow events keep the same advancing audio element
with zero audio loads, pauses, seeks or additional play calls.
- All 155 server tests pass with `YTDLP_PATH=/tmp/ytplayer-test-yt-dlp` (the existing
local test ZIPAPP). The initial default run failed because the system Python
environment lacked `yt_dlp`; no server changes were necessary.
Review on an actual iPhone in Safari and installed PWA: saved MP4, streamed songs,
EQ renders, multiple lock/unlock and app switches, auto-advance while locked,
Media Session controls, seeking and PiP. Test Landscape/Portrait with safe areas,
rotation and touch swipes in both themes. On Android/PWA, verify an actual native
orientation lock and release; browser tests verify API calls with a controlled
orientation implementation.
iOS may suspend JavaScript, pause media or interrupt the audio session during lock,
app switching, route changes or incoming calls. A web app cannot guarantee a zero
OS-level gap. These tests demonstrate removal of application-induced source
reloads and audio seeks; desktop WebKit cannot simulate physical iOS suspension.
Tracks first started in the background still reattach their picture on return;
that existing transition also needs hardware review.
No server imports were added, and nothing was pushed or deployed.

View File

@@ -2495,6 +2495,19 @@ const Player = {
}
},
// Use one uninterrupted audio decoder on iPhone from the first play. Locking
// then promotes that existing clock; it never loads/seeks a new sound source.
prepareBackgroundAudio() {
if (!IS_IOS || this.mode !== 'progressive') return;
const source = AudioContinuity.progressiveSource(current, els.video.getAttribute('src'));
if (!source) return;
this.mode = 'dual';
this.secondary = els.audio;
els.video.muted = true;
els.audio.src = source;
els.audio.load();
},
attach(quality) {
const V = els.video, A = els.audio;
// An uploaded audio file has no picture track: play it like audio-only and
@@ -2508,6 +2521,7 @@ const Player = {
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio') || bgStart;
// Reset
audioContinuity.reset(V); audioContinuity.reset(A);
V.pause(); A.pause();
this.stopDrift();
this._bgHandoff = false;
@@ -2551,6 +2565,7 @@ const Player = {
A.removeAttribute('src'); A.load();
els.art.classList.add('hidden');
}
this.prepareBackgroundAudio();
this.applyVolume();
this.applySpeed();
this.master.load();
@@ -2596,6 +2611,7 @@ const Player = {
return;
}
this.prepareBackgroundAudio();
this.applyVolume();
this.applySpeed();
this.master.load();
@@ -2639,15 +2655,14 @@ const Player = {
play() {
this._wantsPlaying = true;
this.master.play().catch(() => {});
if (this.secondary) {
this.secondary.currentTime = this.master.currentTime;
this.secondary.play().catch(() => {});
}
audioContinuity.play(this.master);
audioContinuity.resume(this.secondary, this.master);
},
pause() {
this._wantsPlaying = false;
this.clearBufferGrace();
audioContinuity.reset(this.master);
if (this.secondary) audioContinuity.reset(this.secondary);
this.master.pause();
if (this.secondary) this.secondary.pause();
},
@@ -2743,8 +2758,7 @@ const Player = {
// The element that's actually producing sound in the current mode.
const sounder = this.soundEl;
if (sounder && sounder.paused) {
sounder.currentTime = this.master.currentTime;
sounder.play().catch(() => {});
audioContinuity.resume(sounder, this.master);
}
}, 1000);
},
@@ -2785,25 +2799,26 @@ const Player = {
if (!src) return; // nothing to continue with — leave playback alone
const V = els.video, A = els.audio;
try {
A.src = src;
A.currentTime = V.currentTime || 0;
this._bgHandoff = true;
this.master = A;
if (A.getAttribute('src') !== src) A.src = src;
if (AudioContinuity.needsAlignment(A.currentTime, V.currentTime)) A.currentTime = V.currentTime || 0;
A.volume = V.volume;
A.muted = V.muted;
const p = A.play();
if (p && p.catch) p.catch(() => {});
V.pause();
// Making <audio> the master keeps soundEl, the drift watchdog and the
// progress UI pointed at whatever is actually producing sound.
this.master = A;
this._bgHandoff = true;
audioContinuity.play(A).then(() => {
if (this._bgHandoff && this.master === A && !A.paused) V.pause();
});
// Master ownership is set before play/pause can dispatch another handler.
} catch {
this._bgHandoff = false;
this.master = V;
}
},
// Undo handoffToBackgroundAudio, putting the video back in charge at the
// position the audio reached while the app was away.
restoreFromBackgroundAudio() {
if (document.visibilityState !== 'visible') return;
// A track that STARTED in the background is audio-only: re-attach its
// picture at the position the audio reached.
if (this._bgAudioMode && current && !data.settings.audioOnly) {
@@ -2823,8 +2838,8 @@ const Player = {
this.master = V;
this.secondary = A;
try {
V.currentTime = t;
if (this._wantsPlaying) { const p = V.play(); if (p && p.catch) p.catch(() => {}); }
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; }
if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ }
return;
}
@@ -2832,14 +2847,11 @@ const Player = {
this._bgHandoff = false;
this.master = V;
try {
V.currentTime = t;
A.pause();
A.removeAttribute('src');
A.load();
if (this._wantsPlaying) {
const p = V.play();
if (p && p.catch) p.catch(() => {});
}
this.mode = 'dual';
this.secondary = A;
V.muted = true;
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; }
if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ }
},
@@ -2851,12 +2863,10 @@ const Player = {
if (!this._wantsPlaying) return;
const sounder = this.soundEl;
if (sounder && sounder.paused) {
sounder.currentTime = this.master.currentTime;
sounder.play().catch(() => {});
audioContinuity.resume(sounder, this.master);
}
if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
this.secondary.currentTime = this.master.currentTime;
this.secondary.play().catch(() => {});
audioContinuity.resume(this.secondary, this.master);
}
},
};
@@ -2933,7 +2943,7 @@ function wirePlayerEvents() {
const masterIs = (el) => Player.master === el;
function bind(el) {
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
el.addEventListener('play', () => { if (masterIs(el)) audioContinuity.resume(Player.secondary, el); updatePlayBtn(); });
el.addEventListener('pause', () => {
// iOS paused the master video because the app went to the background
// (sometimes before visibilitychange arrives): hand the sound over now.
@@ -2983,8 +2993,7 @@ function wirePlayerEvents() {
if (Player.secondary && !el.paused && Player.secondary.paused) {
// Resume-only path (audio silently paused by iOS or a real stall):
// seeking a *paused* audio element is inaudible, so sync then play.
Player.secondary.currentTime = el.currentTime;
Player.secondary.play().catch(() => {});
audioContinuity.resume(Player.secondary, el);
} else if (Player.secondary && !el.paused) {
// Audio kept playing through the video stall — realign the video.
Player.correctDrift();
@@ -6990,11 +6999,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 +7014,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 +7027,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 +7053,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 +9813,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 +11442,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,30 @@
(function (root) {
'use strict';
function progressiveSource(current, videoSource) {
return current?.eqAudioUrl || current?.localUrl || current?.audioUrl || videoSource || '';
}
function needsAlignment(time, clock, threshold = 0.3) {
return Number.isFinite(time) && Number.isFinite(clock) && Math.abs(time - clock) >= threshold;
}
function create() {
const pending = new WeakMap();
function play(element) {
if (pending.has(element)) return pending.get(element);
if (!element.paused) return Promise.resolve();
let request;
try { request = Promise.resolve(element.play()); } catch (error) { request = Promise.reject(error); }
// Preserve the intent to play after an OS interruption; the watchdog retries.
request = request.catch(() => {}).finally(() => { if (pending.get(element) === request) pending.delete(element); });
pending.set(element, request); return request;
}
function resume(element, clock) {
if (!element || !element.paused || pending.has(element)) return;
if (clock && element !== clock && needsAlignment(element.currentTime, clock.currentTime)) element.currentTime = clock.currentTime;
return play(element);
}
return { play, resume, reset: element => pending.delete(element) };
}
const api = { progressiveSource, needsAlignment, create };
if (typeof module !== 'undefined' && module.exports) module.exports = api;
else { root.AudioContinuity = api; root.audioContinuity = create(); }
})(globalThis);

View File

@@ -0,0 +1,43 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { progressiveSource, needsAlignment, create } = require('./audio-continuity');
test('iPhone progressive audio uses saved/EQ sources before remote fallbacks', () => {
assert.equal(progressiveSource({ localUrl: 'blob:saved', audioUrl: 'remote' }, 'mux'), 'blob:saved');
assert.equal(progressiveSource({ eqAudioUrl: 'blob:eq', localUrl: 'blob:saved' }), 'blob:eq');
assert.equal(progressiveSource({ audioUrl: 'audio' }, 'mux'), 'audio');
assert.equal(progressiveSource({}, 'mux'), 'mux');
});
test('small drift and invalid clocks do not trigger an audible seek', () => {
assert.equal(needsAlignment(50.05, 50), false);
assert.equal(needsAlignment(49, 50), true);
assert.equal(needsAlignment(NaN, 50), false);
});
test('simultaneous play, visibility and watchdog resumes share one pending play', async () => {
let finish, calls = 0, seeks = 0;
const audio = { paused: true, get currentTime() { return 10; }, set currentTime(value) { seeks++; }, play() { calls++; return new Promise(resolve => { finish = resolve; }); } };
const controller = create();
controller.resume(audio, { currentTime: 10.05 });
controller.resume(audio, { currentTime: 12 });
const pending = controller.play(audio);
assert.equal(calls, 1); assert.equal(seeks, 0);
finish(); await pending;
controller.resume(audio, audio); // never seek an element to itself
assert.equal(seeks, 0); assert.equal(calls, 2); finish();
});
test('rejected autoplay can retry and only paused, misaligned secondaries seek', async () => {
const controller = create(); let calls = 0;
const audio = { paused: true, currentTime: 0, play() { calls++; return Promise.reject(new Error('OS suspension')); } };
await controller.play(audio); await controller.resume(audio, { currentTime: 4 });
assert.equal(calls, 2); assert.equal(audio.currentTime, 4);
audio.paused = false; controller.resume(audio, { currentTime: 10 });
assert.equal(audio.currentTime, 4); assert.equal(calls, 2);
});
test('a new play after pause/source reset is not blocked by a stale promise', async () => {
const controller = create(), finishes = []; let calls = 0;
const audio = { paused: true, play() { calls++; return new Promise(resolve => finishes.push(resolve)); } };
const old = controller.play(audio); controller.reset(audio);
const fresh = controller.play(audio); assert.equal(calls, 2);
finishes[0](); await old;
assert.equal(controller.play(audio), fresh); // old completion cannot discard the new request
finishes[1](); await fresh;
});

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,8 @@
<script src="p2p-transfer.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="audio-continuity.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,9 @@ const SHELL = [
'/p2p-transfer.js',
'/p2p-recv-worker.js',
'/settings-sections.js',
'/audio-continuity.js',
'/fullscreen-orientation.js',
'/fullscreen-orientation.css',
'/download-errors.js',
'/downloads.js',
'/downloads-page.js',

View File

@@ -0,0 +1,2 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({ testDir: './tests', testMatch: /audio-continuity\.spec\.js/, timeout: 30000, workers: 1, projects: [{ name: 'chromium', use: { browserName: 'chromium' } }, { name: 'webkit', use: { browserName: 'webkit' } }], use: { baseURL: 'http://localhost:8098', viewport: { width: 390, height: 844 }, serviceWorkers: 'block' }, webServer: { command: 'npx serve frontend -l 8098 --no-clipboard', port: 8098 } });

View File

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

View File

@@ -0,0 +1,46 @@
const { test, expect } = require('@playwright/test');
const { openClassic, showPlayingFixture } = require('./helpers/classic-fixture');
// A small local PCM fixture exercises actual media elements without YouTube.
function wav() {
const rate = 8000, samples = rate * 12, bytes = Buffer.alloc(44 + samples * 2);
bytes.write('RIFF'); bytes.writeUInt32LE(bytes.length - 8, 4); bytes.write('WAVEfmt ', 8);
bytes.writeUInt32LE(16, 16); bytes.writeUInt16LE(1, 20); bytes.writeUInt16LE(1, 22);
bytes.writeUInt32LE(rate, 24); bytes.writeUInt32LE(rate * 2, 28); bytes.writeUInt16LE(2, 32); bytes.writeUInt16LE(16, 34);
bytes.write('data', 36); bytes.writeUInt32LE(samples * 2, 40);
for (let i = 0; i < samples; i++) bytes.writeInt16LE(Math.sin(i * 2 * Math.PI * 220 / rate) * 2000, 44 + i * 2);
return bytes;
}
for (const source of ['saved', 'streamed']) test(`${source}: iPhone keeps the same playing audio across repeated lock/unlock events`, async ({ page }) => {
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15' }));
await page.route('**/continuity.wav', route => route.fulfill({ contentType: 'audio/wav', body: wav() }));
await openClassic(page, { populated: true }); await showPlayingFixture(page);
await page.evaluate(source => {
current.localUrl = source === 'saved' ? '/continuity.wav' : '';
current.audioUrl = '/continuity.wav';
Player.attach(source === 'streamed' ? { url: '/continuity.wav', hasAudio: true } : null);
}, source);
await expect.poll(() => page.evaluate(() => els.audio.readyState)).toBeGreaterThanOrEqual(2);
await page.evaluate(() => Player.play());
await expect.poll(() => page.evaluate(() => !els.audio.paused && els.audio.currentTime > 0)).toBe(true);
const result = await page.evaluate(async () => {
const A = els.audio, V = els.video;
const initial = A.currentTime, src = A.src;
let playCalls = 0, audioSeeks = 0, loads = 0, pauses = 0;
const play = A.play.bind(A), load = A.load.bind(A), pause = A.pause.bind(A);
A.play = () => { playCalls++; return play(); }; A.load = () => { loads++; load(); }; A.pause = () => { pauses++; pause(); };
A.addEventListener('seeking', () => audioSeeks++);
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' });
document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('pagehide'));
window.dispatchEvent(new Event('focus')); // focus arriving while hidden must not restore
const promoted = Player.master === A && Player._bgHandoff === 'dual';
V.pause();
await new Promise(resolve => setTimeout(resolve, 350));
const advanced = A.currentTime > initial;
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' });
document.dispatchEvent(new Event('visibilitychange')); window.dispatchEvent(new Event('pageshow')); window.dispatchEvent(new Event('focus'));
await new Promise(resolve => setTimeout(resolve, 200));
const answer = { mode: Player.mode, promoted, advanced, sameSource: A.src === src, playing: !A.paused, playCalls, audioSeeks, loads, pauses, restored: Player.master === V && Player.secondary === A };
Player.pause(); Player.stopDrift(); return answer;
});
expect(result).toEqual({ mode: 'dual', promoted: true, advanced: true, sameSource: true, playing: true, playCalls: 0, audioSeeks: 0, loads: 0, pauses: 0, restored: true });
});

View File

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