feat: audio playback intermittent issue

Task #50 completed by ClaudeQueue

ClaudeQueue
This commit is contained in:
Claude Worker
2026-07-01 01:53:09 +00:00
parent 7cf3517dd3
commit ffeef5d520
2 changed files with 134 additions and 3 deletions

View File

@@ -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);
}