Show the video inline in service mode and keep audio alive in the background
This commit is contained in:
121
frontend/app.js
121
frontend/app.js
@@ -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(); });
|
||||
|
||||
Reference in New Issue
Block a user