Show the video inline in service mode and keep audio alive in the background

This commit is contained in:
Jonathan Sykes
2026-09-08 17:36:55 +08:00
parent a3089dde74
commit 292a0c7e29
3 changed files with 124 additions and 54 deletions

View File

@@ -1362,6 +1362,7 @@ const Player = {
secondary: null, // synced audio element in dual mode
driftTimer: null,
bufferGraceTimer: null, // pending "pause audio after a stall that outlasts the grace window" timer
_bgHandoff: false, // sound temporarily moved to <audio> for background play
_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)
@@ -1794,6 +1795,54 @@ const Player = {
this.bufferGraceTimer = null;
}
},
// iOS suspends a backgrounded <video> outright, so in 'progressive' mode
// (where the video element IS the sound source) locking the screen kills the
// audio with nothing left playing. An <audio> element is allowed to continue,
// so hand the sound over to it on the way out and hand it back on return.
// 'dual' and 'audio' modes already play through <audio> and need no handoff.
handoffToBackgroundAudio() {
if (!this._wantsPlaying || this._bgHandoff) return;
if (this.mode !== 'progressive') return;
const src = (current && (current.localUrl || current.audioUrl)) || '';
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;
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;
} catch {
this._bgHandoff = false;
}
},
// Undo handoffToBackgroundAudio, putting the video back in charge at the
// position the audio reached while the app was away.
restoreFromBackgroundAudio() {
if (!this._bgHandoff) return;
const V = els.video, A = els.audio;
const t = A.currentTime || 0;
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(() => {});
}
} catch { /* the watchdog picks up whatever is left */ }
},
// 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
@@ -1819,15 +1868,23 @@ const Player = {
// paused, video kept playing" UX.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
} else {
// Fires while the page can still start media, which is what makes the
// handoff possible at all — once iOS has fully suspended us, play() is
// rejected.
Player.handoffToBackgroundAudio();
}
});
window.addEventListener('pageshow', () => {
Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
});
window.addEventListener('focus', () => {
Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
});
@@ -1876,7 +1933,19 @@ function wirePlayerEvents() {
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('pause', () => { if (masterIs(el) && Player.secondary) { Player.clearBufferGrace(); Player.secondary.pause(); } updatePlayBtn(); });
el.addEventListener('pause', () => {
if (masterIs(el) && Player.secondary) {
Player.clearBufferGrace();
// ONLY a deliberate pause stops the synced audio. iOS pauses a
// backgrounded <video> by itself on screen-lock or app-switch, and
// propagating that to the audio element is what silenced playback in
// the background — the audio is allowed to keep running there, so it
// should. Player.pause() clears _wantsPlaying before pausing the
// master, so intent is what separates the two cases.
if (!Player._wantsPlaying) Player.secondary.pause();
}
updatePlayBtn();
});
el.addEventListener('seeking', () => {
if (!masterIs(el) || !Player.secondary) return;
// Drift correction seeks the muted video onto the audio clock; mirroring
@@ -2356,40 +2425,36 @@ function serviceVideoOn() {
return !!data.settings.serviceVideo && !data.settings.audioOnly;
}
function positionServiceVideo() {
const stage = document.querySelector('.player-stage');
if (!stage) return;
if (!(isServiceModeOpen() && serviceVideoOn())) {
stage.style.position = '';
stage.style.top = '';
stage.style.left = '';
stage.style.width = '';
stage.style.height = '';
return;
}
const slot = $('serviceVideoSlot');
if (!slot) return;
const r = slot.getBoundingClientRect();
if (!r.width || !r.height) return; // slot not laid out yet
stage.style.position = 'fixed';
stage.style.top = `${r.top}px`;
stage.style.left = `${r.left}px`;
stage.style.width = `${r.width}px`;
stage.style.height = `${r.height}px`;
// The overlay normally sits at body level with z-index 320, and nothing inside
// .app can paint above it: .app is `position: relative; z-index: 1`, which makes
// it a stacking context, so a raised z-index on .player-stage is capped by
// .app's own layer. (On iOS the backdrop-filter on .main compounds this by also
// making .main the containing block for fixed descendants.) An earlier attempt
// pinned .player-stage over a slot in the overlay; the geometry was exact but
// the video stayed buried behind it.
//
// So to show the video we move the OVERLAY into the player pane and render it
// inline beneath the stage, instead of trying to lift the video out. Only divs
// and buttons move — the <video> element never does, because reparenting a
// playing video breaks playback on iOS.
function applyServiceVideoLayout() {
const overlay = $('serviceMode');
const pane = els.playerPane;
if (!overlay || !pane) return;
const on = isServiceModeOpen() && serviceVideoOn();
const host = on ? pane : document.body;
if (overlay.parentNode !== host) host.appendChild(overlay);
document.body.classList.toggle('service-video', on);
}
function updateServiceVideoUI() {
const on = isServiceModeOpen() && serviceVideoOn();
document.body.classList.toggle('service-video', on);
applyServiceVideoLayout();
const btn = $('serviceVideoBtn');
if (btn) {
btn.textContent = serviceVideoOn() ? '📺 Video on' : '📺 Video off';
btn.setAttribute('aria-pressed', serviceVideoOn() ? 'true' : 'false');
btn.classList.toggle('on', serviceVideoOn());
}
// The slot only has a box once the class above has been applied, so measure
// on the next frame rather than in this one.
requestAnimationFrame(positionServiceVideo);
}
function toggleServiceVideo() {
@@ -4584,10 +4649,6 @@ document.querySelectorAll('.chip').forEach((c) => {
$('servicePrevBtn').addEventListener('click', playPrev);
$('serviceExitBtn').addEventListener('click', exitServiceMode);
$('serviceVideoBtn').addEventListener('click', toggleServiceVideo);
// The pinned panel is positioned in viewport coordinates, so it has to be
// re-measured whenever the viewport changes shape.
window.addEventListener('resize', positionServiceVideo);
window.addEventListener('orientationchange', () => setTimeout(positionServiceVideo, 150));
// Mini now-playing bar
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });