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,
|
master: els.video,
|
||||||
secondary: null, // synced audio element in dual mode
|
secondary: null, // synced audio element in dual mode
|
||||||
driftTimer: null,
|
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() {
|
get soundEl() {
|
||||||
return this.mode === 'dual' ? els.audio : this.master;
|
return this.mode === 'dual' ? els.audio : this.master;
|
||||||
@@ -603,6 +612,10 @@ const Player = {
|
|||||||
this.master.load();
|
this.master.load();
|
||||||
const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); };
|
const onReadyLocal = () => { this.play(); this.master.removeEventListener('canplay', onReadyLocal); };
|
||||||
this.master.addEventListener('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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -653,6 +666,7 @@ const Player = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
play() {
|
play() {
|
||||||
|
this._wantsPlaying = true;
|
||||||
this.master.play().catch(() => {});
|
this.master.play().catch(() => {});
|
||||||
if (this.secondary) {
|
if (this.secondary) {
|
||||||
this.secondary.currentTime = this.master.currentTime;
|
this.secondary.currentTime = this.master.currentTime;
|
||||||
@@ -660,6 +674,7 @@ const Player = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
pause() {
|
pause() {
|
||||||
|
this._wantsPlaying = false;
|
||||||
this.master.pause();
|
this.master.pause();
|
||||||
if (this.secondary) this.secondary.pause();
|
if (this.secondary) this.secondary.pause();
|
||||||
},
|
},
|
||||||
@@ -683,20 +698,64 @@ const Player = {
|
|||||||
if (this.secondary) this.secondary.playbackRate = r;
|
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() {
|
startDrift() {
|
||||||
this.stopDrift();
|
this.stopDrift();
|
||||||
this.driftTimer = setInterval(() => {
|
this.driftTimer = setInterval(() => {
|
||||||
if (this.mode !== 'dual' || this.master.paused) return;
|
if (!this._wantsPlaying) return;
|
||||||
const drift = Math.abs(this.secondary.currentTime - this.master.currentTime);
|
// Dual-mode: keep the secondary audio track in sync with the master.
|
||||||
if (drift > 0.3) this.secondary.currentTime = this.master.currentTime;
|
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);
|
}, 1000);
|
||||||
},
|
},
|
||||||
stopDrift() {
|
stopDrift() {
|
||||||
if (this.driftTimer) clearInterval(this.driftTimer);
|
if (this.driftTimer) clearInterval(this.driftTimer);
|
||||||
this.driftTimer = null;
|
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) {
|
function showSpinner(on) {
|
||||||
els.spinner.classList.toggle('hidden', !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