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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-09-21 16:39:17 +00:00
parent b4bc90372d
commit b8a51d9ee2
2 changed files with 244 additions and 0 deletions

View File

@@ -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 <video> is torn down to free
@@ -684,6 +737,11 @@ App.feed = App.feed || {};
event.stopPropagation();
pane._muted = !pane._muted;
syncMute();
// Asking for this panel's sound gives up another's, where only one
// panel may have any -- and the one that lost it may have been
// stopped by the system on the way, so it is started again.
limitAudibleToOne(pane);
resumeActivePanes();
refreshFeedMuteState();
// The panel you can hear is the one that should follow you out.
updateAutoPiPTarget();
@@ -952,6 +1010,9 @@ App.feed = App.feed || {};
const activeSlide = slidesByIndex.get(clamped);
if (activeSlide) {
// Settled before anything starts: a second panel starting with
// sound is exactly what stops the first one (see audioIsExclusive).
limitAudibleToOne();
panesOf(activeSlide).forEach((pane) => {
loadSlideSource(pane, pane._videoData, true);
requestAnimationFrame(() => measureFeedTitle(pane));
@@ -1548,6 +1609,10 @@ App.feed = App.feed || {};
if (pane._syncMute) pane._syncMute();
});
});
// Unmuting everything is still one panel's worth of sound where the
// device allows only one; the rest keep the picture.
limitAudibleToOne();
resumeActivePanes();
App.feed.updateMuteButton();
};