From df4acfc9f13e55fb4a41cdea04fca4f8523440b5 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sun, 4 Oct 2026 02:29:10 +0800 Subject: [PATCH] Keep iPhone audio playing through background handoffs --- docs/fullscreen-background-audio.md | 88 +++++++++++++++++++++++++++ frontend/app.js | 75 +++++++++++++---------- frontend/audio-continuity.js | 30 +++++++++ frontend/audio-continuity.test.js | 43 +++++++++++++ frontend/index.html | 1 + frontend/sw.js | 1 + playwright.audio-continuity.config.js | 2 + tests/audio-continuity.spec.js | 46 ++++++++++++++ 8 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 docs/fullscreen-background-audio.md create mode 100644 frontend/audio-continuity.js create mode 100644 frontend/audio-continuity.test.js create mode 100644 playwright.audio-continuity.config.js create mode 100644 tests/audio-continuity.spec.js diff --git a/docs/fullscreen-background-audio.md b/docs/fullscreen-background-audio.md new file mode 100644 index 0000000..1a31ee4 --- /dev/null +++ b/docs/fullscreen-background-audio.md @@ -0,0 +1,88 @@ +# Fullscreen orientation and iPhone audio continuity + +## Task A — done + +Settings → Listening → Video & fullscreen offers Auto (follow device), Landscape, +and Portrait. The choice persists on this device and can be cycled from the +fullscreen controls. Supported browsers request fullscreen and then lock the +selected orientation; Auto leaves orientation unlocked. Exit releases our lock. + +When orientation locking or element fullscreen is unavailable, the player uses a +viewport overlay. A portrait viewport with Landscape selected rotates the existing +stage and its controls 90 degrees; Portrait in a landscape viewport rotates it +in the opposite direction. Safe-area padding follows the rotated axes. Touch +seeking, double-tap seeking, volume/brightness swipes and pinch movement use +inverse coordinates. The video stays in its existing DOM position to avoid +interrupting WebKit playback. Controls and browser fullscreen exit restore the +inline surface, including when a native orientation lock was rejected. + +Safari's custom overlay fills the available dynamic viewport; it cannot force +Safari to hide browser chrome or change the phone's physical orientation. An +installed PWA has no browser toolbar occupying that viewport. + +Files: `frontend/fullscreen-orientation.js`, `.css`, `.test.js`, small hooks in +`app.js`, `index.html`, `sw.js`, `tests/fullscreen-orientation.spec.js` and +`playwright.fullscreen.config.js`. + +## Task B — done; physical iPhone verification remains + +The previous progressive handoff loaded the audio source and sought it only when +the page was hidden, then paused the video before transferring master ownership. +That required a cold audio decoder start and allowed pause-handler re-entry. +Foreground recovery also sought the video, whose shared seeking handler then +sought the still-playing audio. Repeated play/playing/focus/watchdog handlers +could independently issue the same audio play/seek requests. + +On iPhone, progressive playback now uses the existing dual playback path from the +start: a muted video and an audible audio element. Saved files use the same local +source; streams prefer their audio-only URL, falling back to the muxed source. +EQ-render playback already used this path and remains unchanged. Locking promotes +the already-playing audio to master without loading, seeking or restarting it. +Unlocking aligns only the silent video; its internal seek does not propagate to +the audible audio. Pending play requests are shared, small drift does not cause +seeks, and the watchdog never seeks an element to itself. Hidden focus events do +not restore the video prematurely. The audio clock still owns background progress +and ended events, preserving auto-advance and lock-screen playback intent. + +This uses an audio decoder alongside video while visible. Audio-only playback +and tracks that start while already backgrounded retain their existing paths. +Browsers other than iPhone retain their foreground progressive path. Their cold +handoff fallback now sets master ownership before media events and waits for audio +to play before pausing video; returning keeps that audio playing while video resumes. + +Files: `frontend/audio-continuity.js`, `.test.js`, player/event hooks in `app.js`, +`index.html`, `sw.js`, `tests/audio-continuity.spec.js`, and +`playwright.audio-continuity.config.js`. + +## Validation and hardware review + +- 132 frontend unit tests pass, including orientation mapping, source selection, + small-drift policy, concurrent play deduplication, autoplay rejection retry, and + stale pending requests after pause/source reset. +- `node --check frontend/app.js` and `bun build --no-bundle server/server.js` pass. +- Eight fullscreen browser cases pass in Chromium/WebKit at 390 × 844, covering + Classic and Glass Stage overlays, visible controls, quick toggle, exit/restore, + lock/unlock calls and browser exit after lock rejection. +- Four audio browser cases use real local PCM media in Chromium/WebKit, with an + iPhone user agent, covering saved and streamed progressive attachments. Repeated + visibility/pagehide/focus/pageshow events keep the same advancing audio element + with zero audio loads, pauses, seeks or additional play calls. +- All 155 server tests pass with `YTDLP_PATH=/tmp/ytplayer-test-yt-dlp` (the existing + local test ZIPAPP). The initial default run failed because the system Python + environment lacked `yt_dlp`; no server changes were necessary. + +Review on an actual iPhone in Safari and installed PWA: saved MP4, streamed songs, +EQ renders, multiple lock/unlock and app switches, auto-advance while locked, +Media Session controls, seeking and PiP. Test Landscape/Portrait with safe areas, +rotation and touch swipes in both themes. On Android/PWA, verify an actual native +orientation lock and release; browser tests verify API calls with a controlled +orientation implementation. + +iOS may suspend JavaScript, pause media or interrupt the audio session during lock, +app switching, route changes or incoming calls. A web app cannot guarantee a zero +OS-level gap. These tests demonstrate removal of application-induced source +reloads and audio seeks; desktop WebKit cannot simulate physical iOS suspension. +Tracks first started in the background still reattach their picture on return; +that existing transition also needs hardware review. + +No server imports were added, and nothing was pushed or deployed. diff --git a/frontend/app.js b/frontend/app.js index c06cec9..7373ab1 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -2495,6 +2495,19 @@ const Player = { } }, + // Use one uninterrupted audio decoder on iPhone from the first play. Locking + // then promotes that existing clock; it never loads/seeks a new sound source. + prepareBackgroundAudio() { + if (!IS_IOS || this.mode !== 'progressive') return; + const source = AudioContinuity.progressiveSource(current, els.video.getAttribute('src')); + if (!source) return; + this.mode = 'dual'; + this.secondary = els.audio; + els.video.muted = true; + els.audio.src = source; + els.audio.load(); + }, + attach(quality) { const V = els.video, A = els.audio; // An uploaded audio file has no picture track: play it like audio-only and @@ -2508,6 +2521,7 @@ const Player = { const audioOnly = data.settings.audioOnly || (current && current.kind === 'audio') || bgStart; // Reset + audioContinuity.reset(V); audioContinuity.reset(A); V.pause(); A.pause(); this.stopDrift(); this._bgHandoff = false; @@ -2551,6 +2565,7 @@ const Player = { A.removeAttribute('src'); A.load(); els.art.classList.add('hidden'); } + this.prepareBackgroundAudio(); this.applyVolume(); this.applySpeed(); this.master.load(); @@ -2596,6 +2611,7 @@ const Player = { return; } + this.prepareBackgroundAudio(); this.applyVolume(); this.applySpeed(); this.master.load(); @@ -2639,15 +2655,14 @@ const Player = { play() { this._wantsPlaying = true; - this.master.play().catch(() => {}); - if (this.secondary) { - this.secondary.currentTime = this.master.currentTime; - this.secondary.play().catch(() => {}); - } + audioContinuity.play(this.master); + audioContinuity.resume(this.secondary, this.master); }, pause() { this._wantsPlaying = false; this.clearBufferGrace(); + audioContinuity.reset(this.master); + if (this.secondary) audioContinuity.reset(this.secondary); this.master.pause(); if (this.secondary) this.secondary.pause(); }, @@ -2743,8 +2758,7 @@ const Player = { // The element that's actually producing sound in the current mode. const sounder = this.soundEl; if (sounder && sounder.paused) { - sounder.currentTime = this.master.currentTime; - sounder.play().catch(() => {}); + audioContinuity.resume(sounder, this.master); } }, 1000); }, @@ -2785,25 +2799,26 @@ const Player = { 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; + this._bgHandoff = true; + this.master = A; + if (A.getAttribute('src') !== src) A.src = src; + if (AudioContinuity.needsAlignment(A.currentTime, V.currentTime)) 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