iPhone background audio in every playback mode; Classic playlist card actions; export sheet bar

- Dual mode (separate video + audio, used by adaptive streams and the iPhone EQ
  copies): going to the background now makes the audio the master, so progress,
  Now Playing and auto-advance keep working while iOS has the video suspended
  (the paused video used to be the only source of "ended", so playback stopped
  after the current song). The picture is re-synced on return.
- A track that starts while the app is in the background (auto-advance, the
  lock-screen Next button) starts as audio — iOS will not start a <video> there —
  and gets its picture back at the same position when the app returns.
- The handoff also runs on pagehide and when iOS pauses the master video while
  the page is hidden, for when visibilitychange arrives too late.
- Classic: playlist cards place their three actions as a 2x2 block (note + add
  on top, remove below) instead of the note button wrapping to the bottom-left.
- Save to this device: hide the full progress bar once the file is ready; it
  read as a squashed, broken button above "Save to Photos / Files".
This commit is contained in:
Jonathan Sykes
2026-10-03 15:36:14 +08:00
parent ca836e0cd2
commit 9e960cf70f
3 changed files with 67 additions and 2 deletions

View File

@@ -669,6 +669,7 @@ let playFullMode = false; // "play in full" session: ignore resum
// A-B markers, shuffle, loop and repeat — play the
// list start-to-finish, then stop.
let current = null; // { meta, qualities, audioUrl, localUrl?, eqAudioUrl? }
const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// iPhone: object URL of the playing saved song's rendered EQ/levelled sound.
let _currentEqUrl = null;
async function useEqRender(id) {
@@ -2478,11 +2479,18 @@ const Player = {
const V = els.video, A = els.audio;
// An uploaded audio file has no picture track: play it like audio-only and
// show its cover art where the video would be.
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio');
// iOS won't start a <video> while the app is in the background (screen
// locked, app switched). A track that starts there — auto-advance, the
// lock-screen Next button — plays as audio and gets its picture back when
// the app returns (restoreFromBackgroundAudio).
const bgStart = IS_IOS && document.visibilityState === 'hidden' && !data.settings.audioOnly;
this._bgAudioMode = bgStart;
const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio') || bgStart;
// Reset
V.pause(); A.pause();
this.stopDrift();
this._bgHandoff = false;
if (current.localUrl && current.eqAudioUrl && !audioOnly) {
// iPhone: the saved video's picture (muted) + its rendered EQ/levelled
@@ -2586,6 +2594,13 @@ const Player = {
// up from the bridge's position and release it once we are really playing.
_handoff: null,
_startPlayback() {
if (this._resumeAt != null) {
const t = this._resumeAt, paused = this._resumePaused;
this._resumeAt = null; this._resumePaused = false;
try { if (t > 0.05) this.seek(t); } catch { /* not seekable yet */ }
if (!paused) this.play();
return;
}
const h = this._handoff;
this._handoff = null;
if (!h) { this.play(); return; }
@@ -2735,8 +2750,18 @@ const Player = {
// 'dual' and 'audio' modes already play through <audio> and need no handoff.
handoffToBackgroundAudio() {
if (!this._wantsPlaying || this._bgHandoff) return;
if (this.mode === 'dual' && this.secondary === els.audio) {
// Dual mode: the sound already comes from <audio>, but the paused video
// was still the master — the clock, and the source of "ended". Make the
// audio the master so the progress, Now Playing and the next song keep
// going while the video is suspended.
this.master = els.audio;
this.secondary = null;
this._bgHandoff = 'dual';
return;
}
if (this.mode !== 'progressive') return;
const src = (current && (current.localUrl || current.audioUrl)) || '';
const src = (current && (current.eqAudioUrl || current.localUrl || current.audioUrl)) || '';
if (!src) return; // nothing to continue with — leave playback alone
const V = els.video, A = els.audio;
try {
@@ -2759,8 +2784,30 @@ const Player = {
// Undo handoffToBackgroundAudio, putting the video back in charge at the
// position the audio reached while the app was away.
restoreFromBackgroundAudio() {
// 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) {
const t = (this.master && this.master.currentTime) || 0;
const wasPlaying = this._wantsPlaying;
this._bgAudioMode = false;
this._resumeAt = t;
this._resumePaused = !wasPlaying;
this.attach(current.localUrl ? null : chooseQuality());
return;
}
if (!this._bgHandoff) return;
const V = els.video, A = els.audio;
if (this._bgHandoff === 'dual') {
const t = A.currentTime || 0;
this._bgHandoff = false;
this.master = V;
this.secondary = A;
try {
V.currentTime = t;
if (this._wantsPlaying) { const p = V.play(); if (p && p.catch) p.catch(() => {}); }
} catch { /* the watchdog picks up whatever is left */ }
return;
}
const t = A.currentTime || 0;
this._bgHandoff = false;
this.master = V;
@@ -2811,6 +2858,7 @@ document.addEventListener('visibilitychange', () => {
Player.handoffToBackgroundAudio();
}
});
window.addEventListener('pagehide', () => Player.handoffToBackgroundAudio());
window.addEventListener('pageshow', () => {
Player.restoreFromBackgroundAudio();
Player.resumeIfNeeded();
@@ -2867,6 +2915,11 @@ 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', () => {
// iOS paused the master video because the app went to the background
// (sometimes before visibilitychange arrives): hand the sound over now.
if (el === V && masterIs(el) && Player._wantsPlaying && document.visibilityState === 'hidden') {
Player.handoffToBackgroundAudio();
}
if (masterIs(el) && Player.secondary) {
Player.clearBufferGrace();
// ONLY a deliberate pause stops the synced audio. iOS pauses a
@@ -8962,6 +9015,8 @@ async function exportToDevice(video) {
const ext = (String(file.name || '').match(/\.([a-z0-9]{2,5})$/i) || [, 'mp4'])[1];
const name = DE.fileName(title, ext, id);
pct(100);
// A full progress bar above the buttons read as a squashed, broken button.
bar.hidden = true;
say(`${name} · ${fmtBytes(file.size)}`);
actions.innerHTML = '';
if (DE.canShareFile(file)) {

View File

@@ -598,3 +598,4 @@
outline: none; overflow-wrap: anywhere; transition: border-color 0.16s, box-shadow 0.16s;
}
.modal textarea.rename-input:focus { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(255,75,50,0.12); }
.export-bar[hidden] { display: none; }

View File

@@ -226,3 +226,12 @@ html[data-layout="classic"][data-density="compact"] .thumb { width: 88px; }
@media (max-width: 380px) {
html[data-layout="classic"] .thumb { width: 88px; }
}
/* Playlist cards carry a third action (✎ note). Without its own cell it
wrapped to a new row at the bottom-left; give the actions a 2×2 block:
✎ + on top, × under +. */
html[data-layout="classic"] .card:has(.card-note-btn) { grid-template-columns: 88px minmax(0, 1fr) 44px 44px; gap: 4px 12px; }
html[data-layout="classic"] .card:has(.card-note-btn) .card-note-btn { grid-column: 3; grid-row: 1; align-self: start; justify-self: center; width: 44px; height: 44px; border-radius: 8px; opacity: 1; }
html[data-layout="classic"] .card:has(.card-note-btn) .card-menu { grid-column: 4; grid-row: 1; }
html[data-layout="classic"] .card:has(.card-note-btn) .card-del { grid-column: 4; grid-row: 2; }
html[data-layout="classic"] .card:has(.card-note-btn) { column-gap: 8px; }