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(); });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user