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:
103
frontend/app.js
103
frontend/app.js
@@ -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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user