fix: make audio the playback clock in dual mode so video rebuffering never pauses or seeks live audio; harden viewport anchor guard and add modal backdrop dismiss

This commit is contained in:
Jonathan Sykes
2026-07-02 23:58:42 +08:00
parent ed9f93b0b7
commit ba05f0dafe

View File

@@ -731,26 +731,39 @@ const Player = {
// Shared by the 1s watchdog tick and the master 'playing' handler so both
// paths get the same no-pop behavior.
//
// AUDIO IS THE CLOCK. Corrections are applied to the muted <video>, never
// to the playing <audio>: seeking or rate-shifting the silent element is
// invisible, while the same operation on the sounding element is exactly
// the "stutter" users hear. On a phone that can't sustain the video
// bitrate, the video rebuffers in a loop — under the old master-is-clock
// scheme every one of those cycles seeked the live audio (and backgrounding
// the app, which halts video decode and this timer, made playback smooth —
// the reported iPhone symptom).
correctDrift() {
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
if (this.secondary.paused) return; // resume paths own this case
const drift = this.secondary.currentTime - this.master.currentTime;
const abs = Math.abs(drift);
if (abs >= this.HARD_SYNC_THRESHOLD) {
this.secondary.currentTime = this.master.currentTime;
// Snap the muted video onto the audio clock (inaudible).
this._internalSeek = true;
this.master.currentTime = this.secondary.currentTime;
this._resyncSpeed();
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
// Audio ahead of video -> slow audio down; audio behind -> speed it up.
// Video behind audio -> speed the video up; ahead -> slow it down.
const base = parseFloat(els.speed.value) || 1;
this.secondary.playbackRate = drift > 0 ? base - this.SLEW_RATE : base + this.SLEW_RATE;
this.master.playbackRate = drift > 0 ? base + this.SLEW_RATE : base - this.SLEW_RATE;
} else {
this._resyncSpeed();
}
},
// Restore the secondary's playbackRate to the user-selected speed once
// drift is within tolerance (or after a hard snap) so a slew correction
// never lingers and overshoots.
// Restore playbackRates to the user-selected speed once drift is within
// tolerance (or after a hard snap) so a slew correction never lingers and
// overshoots.
_resyncSpeed() {
const base = parseFloat(els.speed.value) || 1;
if (this.master && this.master.playbackRate !== base) this.master.playbackRate = base;
if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
},
@@ -866,35 +879,52 @@ function wirePlayerEvents() {
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('pause', () => { if (masterIs(el) && Player.secondary) { Player.clearBufferGrace(); Player.secondary.pause(); } updatePlayBtn(); });
el.addEventListener('seeking', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; });
// A brief 'waiting' (video re-buffering under CPU/memory pressure) used to
// pause the synced audio immediately — every few-hundred-ms video stall
// cut audio output, which is heard as a stutter even though the audio
// pipeline itself was fine. Give the video a short grace window to
// recover on its own before touching the still-playing audio track; only
// pause it if the stall actually outlasts that window.
el.addEventListener('seeking', () => {
if (!masterIs(el) || !Player.secondary) return;
// Drift correction seeks the muted video onto the audio clock; mirroring
// that back onto the audio would re-create the audible snap it exists to
// avoid. Only user/programmatic seeks propagate to the audio.
if (Player._internalSeek) { Player._internalSeek = false; return; }
Player.secondary.currentTime = el.currentTime;
});
// Video re-buffering must NEVER interrupt the audio: the audio element is
// the playback clock (see correctDrift). Earlier versions paused the
// synced audio when a video stall outlasted a 250ms grace window and
// snapped its clock on recovery — on a phone that can't sustain the video
// bitrate that cycle repeats indefinitely and is heard as constant
// stuttering. Now a stalling video just shows the spinner and catches up
// to the audio clock (or gets snapped forward by the watchdog) when ready.
el.addEventListener('waiting', () => {
if (masterIs(el)) showSpinner(true);
});
el.addEventListener('playing', () => {
if (!masterIs(el)) return;
showSpinner(true);
if (Player.secondary && !Player.secondary.paused) {
Player.clearBufferGrace();
Player.bufferGraceTimer = setTimeout(() => {
Player.bufferGraceTimer = null;
if (Player.master.paused || Player.master.readyState < 3) {
if (Player.secondary) Player.secondary.pause();
}
}, 250);
showSpinner(false);
Player.clearBufferGrace();
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(() => {});
} else if (Player.secondary && !el.paused) {
// Audio kept playing through the video stall — realign the video.
Player.correctDrift();
}
});
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); Player.clearBufferGrace(); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
el.addEventListener('canplay', () => { if (masterIs(el)) showSpinner(false); });
el.addEventListener('timeupdate', () => {
if (masterIs(el)) {
updateProgress();
// Persist playback position every 10s
// Persist playback position every 10s. timeupdate fires ~4×/s, so
// gate on the 10s bucket actually changing — otherwise persist()
// (full JSON.stringify + synchronous localStorage write) ran ~4
// times back-to-back within each matching second, a periodic
// main-thread stall on phones.
if (current && current.meta) {
const t = Player.master.currentTime;
if (t > 5 && Math.floor(t) % 10 === 0) {
const bucket = Math.floor(t / 10);
if (t > 5 && bucket !== Player._lastPersistBucket) {
Player._lastPersistBucket = bucket;
data.resumePositions[current.meta.id] = t;
persist();
}
@@ -2268,6 +2298,15 @@ function showModal(title, bodyNode, actions) {
}
function closeModal() { $('modal').classList.add('hidden'); }
// Tapping the dimmed backdrop dismisses the modal (same as Cancel/Later).
// Guarantees a modal can never permanently swallow every tap on the page —
// the full-screen backdrop sits above the bottom nav, so a modal the user
// doesn't notice (e.g. the update prompt popping under their thumb) used to
// read as "all navigation buttons stopped working".
$('modal').addEventListener('click', (e) => {
if (e.target === $('modal')) closeModal();
});
// ============================================================================
// Portrait PWA detection & behavioral layer
// Mirrors the CSS media query: (display-mode: standalone) and (orientation: portrait)
@@ -2346,6 +2385,20 @@ function setupViewportAnchorGuard() {
// Exiting native video fullscreen is the most reliable reproducer of the
// stray-scroll state; the scroll event alone doesn't always fire for it.
els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50));
// iOS can offset the layout viewport WITHOUT firing a window scroll event
// (keyboard dismissal animations, rotation mid-playback, in-app browser
// chrome changes). visualViewport does report those; hook it when present.
if (window.visualViewport) {
window.visualViewport.addEventListener('scroll', () => setTimeout(reanchor, 50));
window.visualViewport.addEventListener('resize', () => setTimeout(reanchor, 50));
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') setTimeout(reanchor, 50);
});
// Last-resort safety net for offsets none of the events above report:
// a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
// so "nav taps silently do nothing" can never persist longer than a beat.
setInterval(reanchor, 2000);
}
// ============================================================================