feat: audio playback issue
Task #60 completed by ClaudeQueue ClaudeQueue
This commit is contained in:
@@ -407,6 +407,7 @@ const Player = {
|
|||||||
master: els.video,
|
master: els.video,
|
||||||
secondary: null, // synced audio element in dual mode
|
secondary: null, // synced audio element in dual mode
|
||||||
driftTimer: null,
|
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
|
_wantsPlaying: false, // tracks user/app *intent* to be playing, independent
|
||||||
// of what the underlying <audio>/<video> elements
|
// of what the underlying <audio>/<video> elements
|
||||||
// report — iOS Safari (esp. PWA/standalone on iPhone)
|
// report — iOS Safari (esp. PWA/standalone on iPhone)
|
||||||
@@ -675,6 +676,7 @@ const Player = {
|
|||||||
},
|
},
|
||||||
pause() {
|
pause() {
|
||||||
this._wantsPlaying = false;
|
this._wantsPlaying = false;
|
||||||
|
this.clearBufferGrace();
|
||||||
this.master.pause();
|
this.master.pause();
|
||||||
if (this.secondary) this.secondary.pause();
|
if (this.secondary) this.secondary.pause();
|
||||||
},
|
},
|
||||||
@@ -698,6 +700,47 @@ const Player = {
|
|||||||
if (this.secondary) this.secondary.playbackRate = r;
|
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
|
// 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
|
// visibly in PWA/standalone mode on iPhone — can silently pause the element
|
||||||
// actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
|
// actually producing sound (the <audio> tag in 'dual'/'audio' mode) while
|
||||||
@@ -710,11 +753,9 @@ const Player = {
|
|||||||
this.stopDrift();
|
this.stopDrift();
|
||||||
this.driftTimer = setInterval(() => {
|
this.driftTimer = setInterval(() => {
|
||||||
if (!this._wantsPlaying) return;
|
if (!this._wantsPlaying) return;
|
||||||
// Dual-mode: keep the secondary audio track in sync with the master.
|
// Dual-mode: keep the secondary audio track in sync with the master,
|
||||||
if (this.mode === 'dual' && !this.master.paused) {
|
// via the tiered corrector above (no hard snap for small, normal drift).
|
||||||
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
|
this.correctDrift();
|
||||||
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
|
|
||||||
}
|
|
||||||
// The element that's actually producing sound in the current mode.
|
// The element that's actually producing sound in the current mode.
|
||||||
const sounder = this.soundEl;
|
const sounder = this.soundEl;
|
||||||
if (sounder && sounder.paused) {
|
if (sounder && sounder.paused) {
|
||||||
@@ -726,6 +767,17 @@ const Player = {
|
|||||||
stopDrift() {
|
stopDrift() {
|
||||||
if (this.driftTimer) clearInterval(this.driftTimer);
|
if (this.driftTimer) clearInterval(this.driftTimer);
|
||||||
this.driftTimer = null;
|
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
|
// Called when the page/tab regains foreground focus (screen unlock, app
|
||||||
// switch back). This is the single most common trigger for iOS silently
|
// switch back). This is the single most common trigger for iOS silently
|
||||||
@@ -800,10 +852,28 @@ 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.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', () => { if (masterIs(el) && Player.secondary) Player.secondary.currentTime = el.currentTime; });
|
||||||
el.addEventListener('waiting', () => { if (masterIs(el)) { showSpinner(true); if (Player.secondary) Player.secondary.pause(); } });
|
// A brief 'waiting' (video re-buffering under CPU/memory pressure) used to
|
||||||
el.addEventListener('playing', () => { if (masterIs(el)) { showSpinner(false); if (Player.secondary && !el.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } } });
|
// 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('canplay', () => { if (masterIs(el)) showSpinner(false); });
|
||||||
el.addEventListener('timeupdate', () => {
|
el.addEventListener('timeupdate', () => {
|
||||||
if (masterIs(el)) {
|
if (masterIs(el)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user