From a143c8331dfa396956f2d8dfdd388bbb42961ab6 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Tue, 6 Oct 2026 09:14:06 +0800 Subject: [PATCH] Stop video seek loop when returning from background --- frontend/app.js | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/frontend/app.js b/frontend/app.js index fa9ff1f..a213db2 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -2704,6 +2704,24 @@ const Player = { SOFT_SYNC_THRESHOLD: 0.08, HARD_SYNC_THRESHOLD: 0.3, 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 // paths get the same no-pop behavior. @@ -2719,12 +2737,18 @@ const Player = { correctDrift() { if (this.mode !== 'dual' || !this.secondary || this.master.paused) return; 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 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). - this._internalSeek = true; - this.master.currentTime = this.secondary.currentTime; + this.snapVideoTo(this.master, this.secondary); this._resyncSpeed(); } else if (abs >= this.SOFT_SYNC_THRESHOLD) { // Video behind audio -> speed the video up; ahead -> slow it down. @@ -2841,7 +2865,7 @@ const Player = { this.master = V; this.secondary = A; 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); } catch { /* the watchdog picks up whatever is left */ } return; @@ -2853,7 +2877,7 @@ const Player = { this.mode = 'dual'; this.secondary = A; 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); } catch { /* the watchdog picks up whatever is left */ } },