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);
|
||||
}
|
||||
|
||||
72
tests/audio-watchdog.smoke.spec.js
Normal file
72
tests/audio-watchdog.smoke.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user