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

View File

@@ -0,0 +1,72 @@
/**
* Smoke test for the background-audio-resume watchdog (Task #50).
*
* iOS Safari — most visibly in PWA/standalone mode on iPhone — can silently
* pause a background <audio> element (screen lock, app switch) without the
* app reacting, while a muted foreground <video> element keeps rolling. This
* doesn't try to simulate real OS audio suspension (Playwright/Chromium
* can't), but it does verify:
* 1. The new visibilitychange/pageshow/focus listeners don't throw and are
* wired up without breaking page load.
* 2. Player.resumeIfNeeded() actually resumes a sounding element that is
* paused while intent (_wantsPlaying) is true, and leaves it alone when
* intent is false (a real user pause).
*/
const { test, expect } = require('@playwright/test');
test.describe('Background audio resume watchdog', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.app', { state: 'attached' });
});
test('page loads with no JS errors and the new listeners are wired', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push(e.message));
await page.evaluate(() => {
document.dispatchEvent(new Event('visibilitychange'));
window.dispatchEvent(new Event('pageshow'));
window.dispatchEvent(new Event('focus'));
});
expect(errors).toEqual([]);
});
test('resumeIfNeeded() resumes a silently-paused sounding element while intent is true', async ({ page }) => {
const result = await page.evaluate(async () => {
const audio = document.getElementById('audio');
// Simulate a loaded audio-only track without going through the native
// bridge (not available in this headless smoke context).
audio.src = 'data:audio/mpeg;base64,//uQxAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAACAAAJmwAeHh4eHh4eHh4eLi4uLi4uLi4uLi5AQEBAQEBAQEBAQFJSUlJSUlJSUlJSZGRkZGRkZGRkZGR2dnZ2dnZ2dnZ2dg==';
Player.mode = 'audio';
Player.master = audio;
Player.secondary = null;
Player._wantsPlaying = true;
audio.pause(); // simulate iOS silently pausing the sounding element
const pausedBefore = audio.paused;
Player.resumeIfNeeded();
// play() is async; give the browser a tick to apply it.
await new Promise((r) => setTimeout(r, 150));
return { pausedBefore, pausedAfter: audio.paused };
});
expect(result.pausedBefore).toBe(true);
expect(result.pausedAfter).toBe(false);
});
test('resumeIfNeeded() leaves a real user pause alone', async ({ page }) => {
const result = await page.evaluate(async () => {
const audio = document.getElementById('audio');
audio.src = 'data:audio/mpeg;base64,//uQxAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAACAAAJmwAeHh4eHh4eHh4eLi4uLi4uLi4uLi5AQEBAQEBAQEBAQFJSUlJSUlJSUlJSZGRkZGRkZGRkZGR2dnZ2dnZ2dnZ2dg==';
Player.mode = 'audio';
Player.master = audio;
Player.secondary = null;
Player._wantsPlaying = false; // user explicitly paused
audio.pause();
Player.resumeIfNeeded();
await new Promise((r) => setTimeout(r, 150));
return { paused: audio.paused };
});
expect(result.paused).toBe(true);
});
});