Keep iPhone audio playing through background handoffs

This commit is contained in:
Jonathan Sykes
2026-10-04 02:29:10 +08:00
parent 536555e12d
commit df4acfc9f1
8 changed files with 253 additions and 33 deletions

View File

@@ -2495,6 +2495,19 @@ const Player = {
}
},
// Use one uninterrupted audio decoder on iPhone from the first play. Locking
// then promotes that existing clock; it never loads/seeks a new sound source.
prepareBackgroundAudio() {
if (!IS_IOS || this.mode !== 'progressive') return;
const source = AudioContinuity.progressiveSource(current, els.video.getAttribute('src'));
if (!source) return;
this.mode = 'dual';
this.secondary = els.audio;
els.video.muted = true;
els.audio.src = source;
els.audio.load();
},
attach(quality) {
const V = els.video, A = els.audio;
// An uploaded audio file has no picture track: play it like audio-only and
@@ -2508,6 +2521,7 @@ const Player = {
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio') || bgStart;
// Reset
audioContinuity.reset(V); audioContinuity.reset(A);
V.pause(); A.pause();
this.stopDrift();
this._bgHandoff = false;
@@ -2551,6 +2565,7 @@ const Player = {
A.removeAttribute('src'); A.load();
els.art.classList.add('hidden');
}
this.prepareBackgroundAudio();
this.applyVolume();
this.applySpeed();
this.master.load();
@@ -2596,6 +2611,7 @@ const Player = {
return;
}
this.prepareBackgroundAudio();
this.applyVolume();
this.applySpeed();
this.master.load();
@@ -2639,15 +2655,14 @@ const Player = {
play() {
this._wantsPlaying = true;
this.master.play().catch(() => {});
if (this.secondary) {
this.secondary.currentTime = this.master.currentTime;
this.secondary.play().catch(() => {});
}
audioContinuity.play(this.master);
audioContinuity.resume(this.secondary, this.master);
},
pause() {
this._wantsPlaying = false;
this.clearBufferGrace();
audioContinuity.reset(this.master);
if (this.secondary) audioContinuity.reset(this.secondary);
this.master.pause();
if (this.secondary) this.secondary.pause();
},
@@ -2743,8 +2758,7 @@ const Player = {
// 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(() => {});
audioContinuity.resume(sounder, this.master);
}
}, 1000);
},
@@ -2785,25 +2799,26 @@ const Player = {
if (!src) return; // nothing to continue with — leave playback alone
const V = els.video, A = els.audio;
try {
A.src = src;
A.currentTime = V.currentTime || 0;
this._bgHandoff = true;
this.master = A;
if (A.getAttribute('src') !== src) A.src = src;
if (AudioContinuity.needsAlignment(A.currentTime, V.currentTime)) A.currentTime = V.currentTime || 0;
A.volume = V.volume;
A.muted = V.muted;
const p = A.play();
if (p && p.catch) p.catch(() => {});
V.pause();
// Making <audio> the master keeps soundEl, the drift watchdog and the
// progress UI pointed at whatever is actually producing sound.
this.master = A;
this._bgHandoff = true;
audioContinuity.play(A).then(() => {
if (this._bgHandoff && this.master === A && !A.paused) V.pause();
});
// Master ownership is set before play/pause can dispatch another handler.
} catch {
this._bgHandoff = false;
this.master = V;
}
},
// Undo handoffToBackgroundAudio, putting the video back in charge at the
// position the audio reached while the app was away.
restoreFromBackgroundAudio() {
if (document.visibilityState !== 'visible') return;
// A track that STARTED in the background is audio-only: re-attach its
// picture at the position the audio reached.
if (this._bgAudioMode && current && !data.settings.audioOnly) {
@@ -2823,8 +2838,8 @@ const Player = {
this.master = V;
this.secondary = A;
try {
V.currentTime = t;
if (this._wantsPlaying) { const p = V.play(); if (p && p.catch) p.catch(() => {}); }
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; }
if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ }
return;
}
@@ -2832,14 +2847,11 @@ const Player = {
this._bgHandoff = false;
this.master = V;
try {
V.currentTime = t;
A.pause();
A.removeAttribute('src');
A.load();
if (this._wantsPlaying) {
const p = V.play();
if (p && p.catch) p.catch(() => {});
}
this.mode = 'dual';
this.secondary = A;
V.muted = true;
if (AudioContinuity.needsAlignment(V.currentTime, t, 0.12)) { this._internalSeek = true; V.currentTime = t; }
if (this._wantsPlaying) audioContinuity.play(V);
} catch { /* the watchdog picks up whatever is left */ }
},
@@ -2851,12 +2863,10 @@ const Player = {
if (!this._wantsPlaying) return;
const sounder = this.soundEl;
if (sounder && sounder.paused) {
sounder.currentTime = this.master.currentTime;
sounder.play().catch(() => {});
audioContinuity.resume(sounder, this.master);
}
if (this.mode === 'dual' && this.secondary && this.secondary.paused && !this.master.paused) {
this.secondary.currentTime = this.master.currentTime;
this.secondary.play().catch(() => {});
audioContinuity.resume(this.secondary, this.master);
}
},
};
@@ -2933,7 +2943,7 @@ function wirePlayerEvents() {
const masterIs = (el) => Player.master === el;
function bind(el) {
el.addEventListener('play', () => { if (masterIs(el) && Player.secondary && Player.secondary.paused) { Player.secondary.currentTime = el.currentTime; Player.secondary.play().catch(() => {}); } updatePlayBtn(); });
el.addEventListener('play', () => { if (masterIs(el)) audioContinuity.resume(Player.secondary, el); updatePlayBtn(); });
el.addEventListener('pause', () => {
// iOS paused the master video because the app went to the background
// (sometimes before visibilitychange arrives): hand the sound over now.
@@ -2983,8 +2993,7 @@ function wirePlayerEvents() {
if (Player.secondary && !el.paused && Player.secondary.paused) {
// Resume-only path (audio silently paused by iOS or a real stall):
// seeking a *paused* audio element is inaudible, so sync then play.
Player.secondary.currentTime = el.currentTime;
Player.secondary.play().catch(() => {});
audioContinuity.resume(Player.secondary, el);
} else if (Player.secondary && !el.paused) {
// Audio kept playing through the video stall — realign the video.
Player.correctDrift();