feat: audio playback issue
Task #60 completed by ClaudeQueue ClaudeQueue
This commit is contained in:
@@ -407,6 +407,7 @@ const Player = {
|
||||
master: els.video,
|
||||
secondary: null, // synced audio element in dual mode
|
||||
driftTimer: null,
|
||||
bufferGraceTimer: null, // pending "pause audio after a stall that outlasts the grace window" timer
|
||||
_wantsPlaying: false, // tracks user/app *intent* to be playing, independent
|
||||
// of what the underlying <audio>/<video> elements
|
||||
// report — iOS Safari (esp. PWA/standalone on iPhone)
|
||||
@@ -675,6 +676,7 @@ const Player = {
|
||||
},
|
||||
pause() {
|
||||
this._wantsPlaying = false;
|
||||
this.clearBufferGrace();
|
||||
this.master.pause();
|
||||
if (this.secondary) this.secondary.pause();
|
||||
},
|
||||
@@ -698,6 +700,47 @@ const Player = {
|
||||
if (this.secondary) this.secondary.playbackRate = r;
|
||||
},
|
||||
|
||||
// Drift-correction thresholds for dual mode (muted video + synced audio).
|
||||
// A hard `currentTime` jump on a *playing* <audio> element causes an
|
||||
// audible pop/glitch — every earlier version of the watchdog snapped audio
|
||||
// back into sync any time drift exceeded 0.3s, which is exactly what read
|
||||
// as "audio stutter" (video stayed smooth because it was never touched).
|
||||
// We now use a tiered response instead of one hard threshold:
|
||||
// < SOFT_SYNC_THRESHOLD — inaudible, ignore (avoids constant micro-corrections)
|
||||
// < HARD_SYNC_THRESHOLD — gently slew playbackRate so the audio eases back
|
||||
// into sync over ~1s with no audible artifact
|
||||
// >= HARD_SYNC_THRESHOLD — snap (only reached from seeks, buffer flushes,
|
||||
// or background-resume, where a single correction
|
||||
// is unavoidable and rare)
|
||||
SOFT_SYNC_THRESHOLD: 0.08,
|
||||
HARD_SYNC_THRESHOLD: 0.3,
|
||||
SLEW_RATE: 0.04, // playbackRate offset applied while easing back into sync
|
||||
|
||||
// Shared by the 1s watchdog tick and the master 'playing' handler so both
|
||||
// paths get the same no-pop behavior.
|
||||
correctDrift() {
|
||||
if (this.mode !== 'dual' || !this.secondary || this.master.paused) return;
|
||||
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;
|
||||
this._resyncSpeed();
|
||||
} else if (abs >= this.SOFT_SYNC_THRESHOLD) {
|
||||
// Audio ahead of video -> slow audio down; audio behind -> speed it up.
|
||||
const base = parseFloat(els.speed.value) || 1;
|
||||
this.secondary.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.
|
||||
_resyncSpeed() {
|
||||
const base = parseFloat(els.speed.value) || 1;
|
||||
if (this.secondary && this.secondary.playbackRate !== base) this.secondary.playbackRate = base;
|
||||
},
|
||||
|
||||
// Runs for every mode (not just 'dual') as a watchdog: iOS Safari — most
|
||||
// visibly in PWA/standalone mode on iPhone — can silently pause the element
|
||||
// actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
|
||||
@@ -710,11 +753,9 @@ const Player = {
|
||||
this.stopDrift();
|
||||
this.driftTimer = setInterval(() => {
|
||||
if (!this._wantsPlaying) return;
|
||||
// Dual-mode: keep the secondary audio track in sync with the master.
|
||||
if (this.mode === 'dual' && !this.master.paused) {
|
||||
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
|
||||
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
|
||||
}
|
||||
// Dual-mode: keep the secondary audio track in sync with the master,
|
||||
// via the tiered corrector above (no hard snap for small, normal drift).
|
||||
this.correctDrift();
|
||||
// The element that's actually producing sound in the current mode.
|
||||
const sounder = this.soundEl;
|
||||
if (sounder && sounder.paused) {
|
||||
@@ -726,6 +767,17 @@ const Player = {
|
||||
stopDrift() {
|
||||
if (this.driftTimer) clearInterval(this.driftTimer);
|
||||
this.driftTimer = null;
|
||||
this.clearBufferGrace();
|
||||
},
|
||||
// Cancel a pending "pause synced audio after a stall" timer — used once the
|
||||
// video recovers (playing/canplay), on an explicit pause, and when tearing
|
||||
// down the current element (attach()/stopDrift()) so a stale timer never
|
||||
// fires against a track that's already moved on.
|
||||
clearBufferGrace() {
|
||||
if (this.bufferGraceTimer) {
|
||||
clearTimeout(this.bufferGraceTimer);
|
||||
this.bufferGraceTimer = null;
|
||||
}
|
||||
},
|
||||
// Called when the page/tab regains foreground focus (screen unlock, app
|
||||
// switch back). This is the single most common trigger for iOS silently
|
||||
@@ -800,10 +852,28 @@ 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.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('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } });
|
||||
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
|
||||
// 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('waiting', () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
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)) {
|
||||
|
||||
Reference in New Issue
Block a user