Compare commits

...

2 Commits

Author SHA1 Message Date
Jonathan Sykes
63ff2173d8 Lift player to body for iOS fullscreen so the app never shows around it 2026-10-06 09:14:06 +08:00
Jonathan Sykes
a143c8331d Stop video seek loop when returning from background 2026-10-06 09:14:06 +08:00
3 changed files with 42 additions and 7 deletions

View File

@@ -2704,6 +2704,24 @@ const Player = {
SOFT_SYNC_THRESHOLD: 0.08, SOFT_SYNC_THRESHOLD: 0.08,
HARD_SYNC_THRESHOLD: 0.3, HARD_SYNC_THRESHOLD: 0.3,
SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync
SNAP_COOLDOWN_MS: 3000, // after a hard snap, only slew for this long
_seekLag: 0.25, // seconds the last video seek took (measured), used as the snap lead
// Seek the muted video to where the sounding clock WILL be once the seek lands,
// measuring how long seeks take on this device so the lead adapts.
snapVideoTo(V, clock) {
const rate = (clock && clock.playbackRate) || 1;
const lead = clock && !clock.paused ? Math.min(1.5, this._seekLag) * rate : 0;
const target = ((clock && clock.currentTime) || 0) + lead;
const started = performance.now();
this._internalSeek = true;
this._lastSnapAt = Date.now();
V.addEventListener('seeked', () => {
const took = (performance.now() - started) / 1000;
this._seekLag = Math.max(0.05, Math.min(1.5, this._seekLag * 0.5 + took * 0.5));
}, { once: true });
V.currentTime = target;
},
// Shared by the 1s watchdog tick and the master 'playing' handler so both // Shared by the 1s watchdog tick and the master 'playing' handler so both
// paths get the same no-pop behavior. // paths get the same no-pop behavior.
@@ -2719,12 +2737,18 @@ const Player = {
correctDrift() { correctDrift() {
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return; if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
if (this.secondary.paused) return; // resume paths own this case if (this.secondary.paused) return; // resume paths own this case
// A seek still in flight is not drift: re-snapping now restarts the seek and,
// on iPhone, loops "seek → land behind → seek again" (stutter after resuming).
if (this.master.seeking) return;
const drift = this.secondary.currentTime - this.master.currentTime; const drift = this.secondary.currentTime - this.master.currentTime;
const abs = Math.abs(drift); const abs = Math.abs(drift);
if (abs >= this.HARD_SYNC_THRESHOLD) { if (abs >= this.HARD_SYNC_THRESHOLD && Date.now() - (this._lastSnapAt || 0) < this.SNAP_COOLDOWN_MS) {
// Just snapped: ease in instead of snapping again.
const base = parseFloat(els.speed.value) || 1;
this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE * 2 : base - this.SLEW_RATE * 2;
} else if (abs >= this.HARD_SYNC_THRESHOLD) {
// Snap the muted video onto the audio clock (inaudible). // Snap the muted video onto the audio clock (inaudible).
this._internalSeek = true; this.snapVideoTo(this.master, this.secondary);
this.master.currentTime = this.secondary.currentTime;
this._resyncSpeed(); this._resyncSpeed();
} else if (abs >= this.SOFT_SYNC_THRESHOLD) { } else if (abs >= this.SOFT_SYNC_THRESHOLD) {
// Video behind audio -> speed the video up; ahead -> slow it down. // Video behind audio -> speed the video up; ahead -> slow it down.
@@ -2841,7 +2865,7 @@ const Player = {
this.master = V; this.master = V;
this.secondary = A; this.secondary = A;
try { try {
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; } if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) this.snapVideoTo(V, A);
if (this._wantsPlaying) audioContinuity.play(V); if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ } } catch { /* the watchdog picks up whatever is left */ }
return; return;
@@ -2853,7 +2877,7 @@ const Player = {
this.mode = 'dual'; this.mode = 'dual';
this.secondary = A; this.secondary = A;
V.muted = true; V.muted = true;
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; } if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) this.snapVideoTo(V, A);
if (this._wantsPlaying) audioContinuity.play(V); if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ } } catch { /* the watchdog picks up whatever is left */ }
}, },

View File

@@ -69,3 +69,7 @@
/* Raise existing ancestry instead of moving a playing video in WebKit. */ /* Raise 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 .orientation-ancestor { z-index: 10001 !important; transform: none !important; filter: none !important; overflow: visible !important; }
body.landscape-fs { overflow: hidden; } body.landscape-fs { overflow: hidden; }
/* While the app's own fullscreen is up, nothing of the app may show around or over it. */
body.landscape-fs .orientation-ancestor { -webkit-backdrop-filter: none !important; backdrop-filter: none !important; contain: none !important; will-change: auto !important; perspective: none !important; clip-path: none !important; isolation: auto !important; }
body.landscape-fs :is(.topbar, #bottomNav, #miniBar, #sectionRail, #toastContainer, .sheet-backdrop, .f7s-sheet) { visibility: hidden !important; }
body.landscape-fs > .player-stage.orientation-overlay { z-index: 2147483000 !important; }

View File

@@ -17,6 +17,12 @@
let active = false, custom = false, native = false, locked = false, angle = 0, timer, hideTimer; let active = false, custom = false, native = false, locked = false, angle = 0, timer, hideTimer;
const ancestors = []; const ancestors = [];
for (let el = stage.parentElement; el && el !== document.body; el = el.parentElement) ancestors.push(el); for (let el = stage.parentElement; el && el !== document.body; el = el.parentElement) ancestors.push(el);
// iOS (no element fullscreen) draws its own fixed overlay. Any ancestor with a filter,
// backdrop-filter, containment or clipping traps a fixed child, which showed the app
// around the video on iPhone. Lift the stage to <body> for the overlay and put it back after.
let home = null;
function lift() { if (home || stage.parentElement === document.body) return; home = { parent: stage.parentElement, next: stage.nextSibling }; document.body.append(stage); }
function unlift() { if (!home) return; const { parent, next } = home; home = null; if (next && next.parentNode === parent) parent.insertBefore(stage, next); else parent.append(stage); }
const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true; const hud = document.createElement('div'); hud.className = 'fullscreen-hud'; hud.hidden = true;
hud.innerHTML = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause"><svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></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" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>'; hud.innerHTML = '<div class="fullscreen-top"><div class="fullscreen-title" aria-live="polite"></div><button type="button" aria-label="Exit fullscreen" title="Exit fullscreen"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4H4v5m0-5 6 6m5 9h5v-5m0 5-6-6"/></svg></button></div><div class="fullscreen-controls"><button type="button" aria-label="Play or pause" title="Play or pause"><svg class="fill" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></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" title="Change fullscreen orientation"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V4h4M20 16v4h-4M4 4l6 6m4 4 6 6M20 8V4h-4M4 16v4h4M20 4l-6 6m-4 4-6 6"/></svg></button><button type="button" class="fullscreen-fit" aria-label="Change video fit" title="Change video fit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7V4h3M17 4h3v3M20 17v3h-3M7 20H4v-3M7 9h10v6H7z"/></svg></button></div>';
stage.append(hud); stage.append(hud);
@@ -56,7 +62,7 @@
const value = normalize(settings().fullscreenOrientation); const value = normalize(settings().fullscreenOrientation);
if (!custom && value !== 'auto') { if (!custom && value !== 'auto') {
try { await screen.orientation.lock(value); locked = true; if (!active) { screen.orientation.unlock(); locked = false; return; } } 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'); } catch { if (!active) return; custom = true; lift(); stage.classList.add('orientation-overlay'); }
} }
layout(); layout();
} }
@@ -77,6 +83,7 @@
hud.hidden = true; hud.classList.remove('hud-hidden'); stage.classList.remove('orientation-overlay', 'orientation-fullscreen'); 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.fullscreenRotation; document.body.classList.remove('landscape-fs');
delete stage.dataset.fullscreenFit; delete stage.dataset.fullscreenFit;
unlift();
if (locked) { screen.orientation?.unlock?.(); locked = false; } if (locked) { screen.orientation?.unlock?.(); locked = false; }
} }
function leave() { function leave() {
@@ -92,7 +99,7 @@
if (!stage.requestFullscreen) throw new Error('Custom fullscreen'); if (!stage.requestFullscreen) throw new Error('Custom fullscreen');
await stage.requestFullscreen(); await stage.requestFullscreen();
native = true; native = true;
} catch { custom = true; stage.classList.add('orientation-overlay'); } } catch { custom = true; lift(); stage.classList.add('orientation-overlay'); }
if (!active) return; if (!active) return;
await orient(); await orient();
if (!active) return; if (!active) return;