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(); });

View File

@@ -267,10 +267,6 @@
</div>
</div>
<div class="service-center">
<!-- Placeholder the real .player-stage is positioned over while the
video panel is on. The <video> is never moved into here — see
positionServiceVideo() in app.js. -->
<div id="serviceVideoSlot" class="service-video-slot" aria-hidden="true"></div>
<div class="service-meta">
<h1 id="serviceTitle" class="service-title"></h1>
<div id="serviceChannel" class="service-channel"></div>

View File

@@ -2662,36 +2662,50 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
color: var(--text);
}
/* Placeholder the real .player-stage is pinned over. It reserves the space and
defines the geometry; it never contains the video itself. */
.service-video-slot { display: none; }
body.service-video .service-video-slot {
display: block;
width: 100%;
max-width: 720px;
aspect-ratio: 16 / 9;
margin: 0 auto 18px;
border-radius: var(--radius);
background: #000;
/* Video panel on: the overlay is moved into the player pane (see
applyServiceVideoLayout) and rendered inline beneath the stage, so the video
is simply visible where it already is. A full-screen overlay can never show
it — .app is its own stacking context at z-index 1, which caps anything
inside it below the overlay's 320. */
body.service-video .service-mode {
position: static;
inset: auto;
height: auto;
min-height: 0;
z-index: auto;
background: transparent;
overflow: visible;
animation: none;
padding: 12px 0 0;
}
/* Lift the pinned stage above the overlay (320) while it is on loan. */
body.service-open.service-video .player-stage {
z-index: 330;
/* Everything except the stage, the app's own transport controls and the
service info steps aside. */
body.service-video .topbar,
body.service-video .list-pane { display: none; }
body.service-video .player-pane {
padding-top: calc(env(safe-area-inset-top, 0px) + 10px);
}
/* With the picture carrying the song, the type steps back so both fit. */
body.service-video .service-center {
flex: 0 0 auto;
padding: 10px 0;
}
body.service-video .service-title {
font-size: clamp(20px, 4.2vw, 32px);
margin-bottom: 6px;
font-size: clamp(20px, 4.2vw, 30px);
margin-bottom: 4px;
}
body.service-video .service-channel {
font-size: clamp(14px, 2.2vw, 18px);
margin-bottom: 6px;
margin-bottom: 4px;
}
body.service-video .service-upnext {
margin-top: 12px;
margin-top: 10px;
}
body.service-video .service-bottom {
padding-bottom: 14px;
}
.service-exit-btn {
@@ -2903,7 +2917,6 @@ body.service-open .sidebar-backdrop {
max-width: none;
padding: 12px 14px;
}
body.service-video .service-video-slot { margin-bottom: 12px; }
body.service-video .service-title { font-size: clamp(19px, 5.2vw, 26px); }
body.service-video .service-note { display: none; }
.service-time {