From b8a51d9ee232428a18f694cc4a81b077467b9dc5 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 21 Sep 2026 16:39:17 +0000 Subject: [PATCH] Keep every panel playing on iOS, and give one of them the sound iOS gives a page one audible video at a time. Start a second one with sound and the system pauses the first, so on an iPhone a split of two showed one panel playing and one stopped -- the feed-wide unmute set every panel audible, and the panels then took the audio session from each other in turn. Muted video carries no such limit, so where the rule applies exactly one panel keeps its sound and the rest keep their picture: four pictures moving is what the split is for, and four audio tracks at once was never the point. The sound follows whichever panel is asked for it, and a panel the system stopped on the way is started again. Nothing in the platform announces the rule, and by the time the symptom shows -- a video we started, stopped by something that isn't us -- a panel has already died, so it is read off the device rather than discovered. Everywhere else nothing changes: panels keep their own sound. No iPhone here, so the rule is installed into Chromium and the real feed is driven through it. The last check breaks it on purpose -- unmuting every panel by hand does stop one -- so the simulation can't quietly become a no-op. Against the code before this commit the test fails seven checks, showing exactly what was reported: both panels unmuted, the first paused. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN --- frontend/js/feed.js | 65 ++++++++++++++ tests/smoke_ios_split.py | 179 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 tests/smoke_ios_split.py diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 863ee12..2def2db 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -135,6 +135,59 @@ App.feed = App.feed || {}; if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' }); }; + // ------------------------------------------------------------------ + // Sound in a split + // + // iOS gives a page one audible video at a time. Start a second one with + // sound and the system pauses the first -- which, in a split of two or + // four panels, reads as "only one panel is playing". Muted video carries + // no such limit, so where the rule applies exactly one panel keeps its + // sound and the rest are silenced: four pictures moving is what the split + // is for, and four audio tracks at once was never the point. + // + // Nothing in the platform announces this, and by the time the symptom + // shows -- a video we started, stopped by something that isn't us -- the + // reader has already watched a panel die. So it is read off the device + // rather than discovered. + const audioIsExclusive = (function() { + const ua = navigator.userAgent || ''; + if (/iPhone|iPad|iPod/.test(ua)) return true; + // iPadOS 13+ claims to be a Mac; the touch points give it away. + return /Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1; + })(); + + // Leaves at most one audible panel in the step being watched. `prefer` is + // the panel whose sound was just asked for, if any; otherwise the first + // one already audible keeps it. Silenced panels get their own button + // updated too, so what is shown never disagrees with what is heard. + const limitAudibleToOne = function(prefer) { + if (!audioIsExclusive) return; + const slide = slidesByIndex.get(state.feedActiveIndex); + if (!slide) return; + let kept = (prefer && prefer._muted === false) ? prefer : null; + panesOf(slide).forEach((pane) => { + if (pane._muted) return; + if (!kept) { kept = pane; return; } + if (pane === kept) return; + pane._muted = true; + if (pane._syncMute) pane._syncMute(); + }); + }; + + // Starts whatever in the current step is loaded but stopped -- which is + // what iOS leaves behind when a panel's sound is taken away from it. + const resumeActivePanes = function() { + const slide = slidesByIndex.get(state.feedActiveIndex); + if (!slide) return; + panesOf(slide).forEach((pane) => { + if (!pane.classList.contains('is-loaded')) return; + const video = pane.querySelector('.feed-video'); + if (!video || !video.paused) return; + const playPromise = video.play(); + if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); + }); + }; + // Remembers playback position per video id so scrolling away and back // resumes where the user left off. Slides kept in the window are merely // paused (instant resume); slides whose