feat: audio playback intermittent issue
Task #50 completed by ClaudeQueue ClaudeQueue
This commit is contained in:
@@ -407,6 +407,15 @@ const Player = {
|
||||
master: els.video,
|
||||
secondary: null, // synced audio element in dual mode
|
||||
driftTimer: null,
|
||||
_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)
|
||||
// can silently pause a background audio element on
|
||||
// screen-lock or app-switch without ever pausing the
|
||||
// still-visible muted video, and without always
|
||||
// firing a 'pause' event we react to. Comparing
|
||||
// intent against actual element state is how we
|
||||
// detect and recover from that.
|
||||
|
||||
get soundEl() {
|
||||
return this.mode === 'dual' ? els.audio : this.master;
|
||||
@@ -603,6 +612,10 @@ const Player = {
|
||||
this.master.load();
|
||||
const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); };
|
||||
this.master.addEventListener('canplay', onReadyLocal);
|
||||
// Cached/offline playback is just as susceptible to a silently-paused
|
||||
// background audio element as streamed playback (audio-only mode still
|
||||
// routes through <audio>), so it needs the same watchdog.
|
||||
this.startDrift();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -653,6 +666,7 @@ const Player = {
|
||||
},
|
||||
|
||||
play() {
|
||||
this._wantsPlaying = true;
|
||||
this.master.play().catch(() => {});
|
||||
if (this.secondary) {
|
||||
this.secondary.currentTime = this.master.currentTime;
|
||||
@@ -660,6 +674,7 @@ const Player = {
|
||||
}
|
||||
},
|
||||
pause() {
|
||||
this._wantsPlaying = false;
|
||||
this.master.pause();
|
||||
if (this.secondary) this.secondary.pause();
|
||||
},
|
||||
@@ -683,20 +698,64 @@ const Player = {
|
||||
if (this.secondary) this.secondary.playbackRate = r;
|
||||
},
|
||||
|
||||
// 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
|
||||
// the screen is locked or the app is backgrounded, without necessarily
|
||||
// firing a 'pause' event we react to. The muted <video> element in 'dual'
|
||||
// mode is unaffected and keeps rolling, which is why video looks fine while
|
||||
// audio silently drops. Comparing `_wantsPlaying` (our intent) against the
|
||||
// element's actual `.paused` state lets us detect and resume from that.
|
||||
startDrift() {
|
||||
this.stopDrift();
|
||||
this.driftTimer = setInterval(() => {
|
||||
if (this.mode !== 'dual' || this.master.paused) return;
|
||||
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
|
||||
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
|
||||
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;
|
||||
}
|
||||
// The element that's actually producing sound in the current mode.
|
||||
const sounder = this.soundEl;
|
||||
if (sounder && sounder.paused) {
|
||||
sounder.currentTime = this.master.currentTime;
|
||||
sounder.play().catch(() => {});
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
stopDrift() {
|
||||
if (this.driftTimer) clearInterval(this.driftTimer);
|
||||
this.driftTimer = null;
|
||||
},
|
||||
// Called when the page/tab regains foreground focus (screen unlock, app
|
||||
// switch back). This is the single most common trigger for iOS silently
|
||||
// pausing background audio, so we react immediately here instead of
|
||||
// waiting up to 1s for the next startDrift() tick.
|
||||
resumeIfNeeded() {
|
||||
if (!this._wantsPlaying) return;
|
||||
const sounder = this.soundEl;
|
||||
if (sounder && sounder.paused) {
|
||||
sounder.currentTime = this.master.currentTime;
|
||||
sounder.play().catch(() => {});
|
||||
}
|
||||
if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
|
||||
this.secondary.currentTime = this.master.currentTime;
|
||||
this.secondary.play().catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Recover from iOS silently pausing background audio the moment the app
|
||||
// returns to the foreground (screen unlock, switching back from another app,
|
||||
// or the PWA being restored from a frozen/backgrounded state). This fires far
|
||||
// faster than the 1s watchdog interval, which matters for a jarring "audio
|
||||
// paused, video kept playing" UX.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') Player.resumeIfNeeded();
|
||||
});
|
||||
window.addEventListener('pageshow', () => Player.resumeIfNeeded());
|
||||
window.addEventListener('focus', () => Player.resumeIfNeeded());
|
||||
|
||||
function showSpinner(on) {
|
||||
els.spinner.classList.toggle('hidden', !on);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user