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
|
// 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.
|
||||||
|
//
|
||||||
|
// 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() {
|
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
|
||||||
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) {
|
||||||
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();
|
this._resyncSpeed();
|
||||||
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
|
} 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;
|
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 {
|
} else {
|
||||||
this._resyncSpeed();
|
this._resyncSpeed();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Restore the secondary's playbackRate to the user-selected speed once
|
// Restore playbackRates to the user-selected speed once drift is within
|
||||||
// drift is within tolerance (or after a hard snap) so a slew correction
|
// tolerance (or after a hard snap) so a slew correction never lingers and
|
||||||
// never lingers and overshoots.
|
// overshoots.
|
||||||
_resyncSpeed() {
|
_resyncSpeed() {
|
||||||
const base = parseFloat(els.speed.value) || 1;
|
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;
|
if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -866,35 +879,52 @@ function wirePlayerEvents() {
|
|||||||
function bind(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) && 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('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; });
|
el.addEventListener('seeking', () => {
|
||||||
// A brief 'waiting' (video re-buffering under CPU/memory pressure) used to
|
if (!masterIs(el) || !Player.secondary) return;
|
||||||
// pause the synced audio immediately — every few-hundred-ms video stall
|
// Drift correction seeks the muted video onto the audio clock; mirroring
|
||||||
// cut audio output, which is heard as a stutter even though the audio
|
// that back onto the audio would re-create the audible snap it exists to
|
||||||
// pipeline itself was fine. Give the video a short grace window to
|
// avoid. Only user/programmatic seeks propagate to the audio.
|
||||||
// recover on its own before touching the still-playing audio track; only
|
if (Player._internalSeek) { Player._internalSeek = false; return; }
|
||||||
// pause it if the stall actually outlasts that window.
|
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', () => {
|
el.addEventListener('waiting', () => {
|
||||||
|
if (masterIs(el)) showSpinner(true);
|
||||||
|
});
|
||||||
|
el.addEventListener('playing', () => {
|
||||||
if (!masterIs(el)) return;
|
if (!masterIs(el)) return;
|
||||||
showSpinner(true);
|
showSpinner(false);
|
||||||
if (Player.secondary && !Player.secondary.paused) {
|
Player.clearBufferGrace();
|
||||||
Player.clearBufferGrace();
|
if (Player.secondary && !el.paused && Player.secondary.paused) {
|
||||||
Player.bufferGraceTimer = setTimeout(() => {
|
// Resume-only path (audio silently paused by iOS or a real stall):
|
||||||
Player.bufferGraceTimer = null;
|
// seeking a *paused* audio element is inaudible, so sync then play.
|
||||||
if (Player.master.paused || Player.master.readyState < 3) {
|
Player.secondary.currentTime = el.currentTime;
|
||||||
if (Player.secondary) Player.secondary.pause();
|
Player.secondary.play().catch(() => {});
|
||||||
}
|
} else if (Player.secondary && !el.paused) {
|
||||||
}, 250);
|
// 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('canplay', () => { if (masterIs(el)) showSpinner(false); });
|
||||||
el.addEventListener('timeupdate', () => {
|
el.addEventListener('timeupdate', () => {
|
||||||
if (masterIs(el)) {
|
if (masterIs(el)) {
|
||||||
updateProgress();
|
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) {
|
if (current && current.meta) {
|
||||||
const t = Player.master.currentTime;
|
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;
|
data.resumePositions[current.meta.id] = t;
|
||||||
persist();
|
persist();
|
||||||
}
|
}
|
||||||
@@ -2268,6 +2298,15 @@ function showModal(title, bodyNode, actions) {
|
|||||||
}
|
}
|
||||||
function closeModal() { $('modal').classList.add('hidden'); }
|
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
|
// Portrait PWA detection & behavioral layer
|
||||||
// Mirrors the CSS media query: (display-mode: standalone) and (orientation: portrait)
|
// 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
|
// Exiting native video fullscreen is the most reliable reproducer of the
|
||||||
// stray-scroll state; the scroll event alone doesn't always fire for it.
|
// stray-scroll state; the scroll event alone doesn't always fire for it.
|
||||||
els.video.addEventListener('webkitendfullscreen', () => setTimeout(reanchor, 50));
|
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