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' }); 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 // Remembers playback position per video id so scrolling away and back
// resumes where the user left off. Slides kept in the window are merely // resumes where the user left off. Slides kept in the window are merely
// paused (instant resume); slides whose <video> is torn down to free // paused (instant resume); slides whose <video> is torn down to free
@@ -684,6 +737,11 @@ App.feed = App.feed || {};
event.stopPropagation(); event.stopPropagation();
pane._muted = !pane._muted; pane._muted = !pane._muted;
syncMute(); 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(); refreshFeedMuteState();
// The panel you can hear is the one that should follow you out. // The panel you can hear is the one that should follow you out.
updateAutoPiPTarget(); updateAutoPiPTarget();
@@ -952,6 +1010,9 @@ App.feed = App.feed || {};
const activeSlide = slidesByIndex.get(clamped); const activeSlide = slidesByIndex.get(clamped);
if (activeSlide) { 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) => { panesOf(activeSlide).forEach((pane) => {
loadSlideSource(pane, pane._videoData, true); loadSlideSource(pane, pane._videoData, true);
requestAnimationFrame(() => measureFeedTitle(pane)); requestAnimationFrame(() => measureFeedTitle(pane));
@@ -1548,6 +1609,10 @@ App.feed = App.feed || {};
if (pane._syncMute) pane._syncMute(); 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(); App.feed.updateMuteButton();
}; };

179
tests/smoke_ios_split.py Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Split reels on iOS: every panel keeps playing, one panel keeps the sound.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_ios_split.py
There is no iPhone here, and the bug was never about what the video element
does once asked -- it is about what iOS does to the *other* video when one
starts with sound. So the rule is installed into Chromium and the real feed is
driven through it: unmuting a panel used to unmute every panel, and the system
would then stop all but one of them.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
IPHONE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1")
# iOS's rule, near enough: one audible video at a time. Whatever starts (or
# unmutes) with sound takes the audio session, and whatever had it is paused.
AS_IOS = """(() => {
window.__systemPaused = [];
const claimAudio = (winner) => {
document.querySelectorAll('video').forEach((other) => {
if (other === winner || other.paused || other.muted) return;
window.__systemPaused.push(other.currentSrc || 'video');
HTMLMediaElement.prototype.pause.call(other);
});
};
const play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
if (!this.muted) claimAudio(this);
return play.apply(this, arguments);
};
const muted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
configurable: true,
get() { return muted.get.call(this); },
set(value) {
muted.set.call(this, value);
if (!value && !this.paused) claimAudio(this);
},
});
})();"""
PANES = """() => {
const slide = document.querySelector('.feed-slide.is-active');
if (!slide) return null;
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
return panes.map((pane) => {
const video = pane.querySelector('.feed-video');
const btn = pane.querySelector('.feed-pane-mute');
return {
muted: video ? video.muted : null,
flag: pane._muted === undefined ? null : !!pane._muted,
paused: video ? video.paused : null,
loaded: pane.classList.contains('is-loaded'),
button: btn ? btn.textContent : '',
};
});
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def open_reels(page):
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate("""([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}""", [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(1500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(4000)
def main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--autoplay-policy=no-user-gesture-required",
"--renderer-process-limit=1",
"--js-flags=--max-old-space-size=512",
])
context = browser.new_context(
viewport={"width": 430, "height": 930},
user_agent=IPHONE_UA, is_mobile=True, has_touch=True,
device_scale_factor=3)
context.add_init_script(AS_IOS)
page = context.new_page()
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
open_reels(page)
print("\nthe rule is really in force")
c.ok("the page says it is an iPhone",
"iPhone" in page.evaluate("() => navigator.userAgent"))
print("\nsplit in two, with sound on")
page.click(".feed-slide.is-active .feed-pane .feed-pane-split-right")
page.wait_for_timeout(5000)
page.evaluate("() => App.feed.toggleMute()") # the feed-wide unmute
page.wait_for_timeout(3000)
panes = page.evaluate(PANES)
c.ok("there are two panels", panes and len(panes) == 2, str(panes))
audible = [p for p in panes if p["muted"] is False]
c.ok("exactly one of them has sound", len(audible) == 1, str(panes))
c.ok("and the other says so on its own button",
all(p["button"] == "🔇" for p in panes if p["muted"]), str(panes))
c.ok("the flags agree with the elements",
all(p["flag"] == p["muted"] for p in panes), str(panes))
c.ok("both panels are playing",
all(p["paused"] is False for p in panes if p["loaded"]), str(panes))
print("\nmoving the sound to the other panel")
page.click(".feed-slide.is-active .feed-pane:last-of-type .feed-pane-mute")
page.wait_for_timeout(2500)
moved = page.evaluate(PANES)
c.ok("the panel asked for has it", moved[-1]["muted"] is False, str(moved))
c.ok("the first one gave it up", moved[0]["muted"] is True, str(moved))
c.ok("and nothing stopped playing",
all(p["paused"] is False for p in moved if p["loaded"]), str(moved))
print("\nswiping on")
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
" s.scrollTop += s.clientHeight; }")
page.wait_for_timeout(5000)
stepped = page.evaluate(PANES)
c.ok("the next step plays in both panels",
stepped and all(p["paused"] is False for p in stepped if p["loaded"]),
str(stepped))
c.ok("still only one audible", len([p for p in stepped if p["muted"] is False]) <= 1,
str(stepped))
# Without the fix, both panels would be unmuted -- so prove the rule
# installed above actually bites, by breaking it on purpose.
print("\nthe simulated rule is what the fix is for")
page.evaluate("""() => {
document.querySelectorAll('.feed-slide.is-active .feed-video').forEach((v) => {
v.muted = false;
const p = v.play();
if (p && p.catch) p.catch(() => {});
});
}""")
page.wait_for_timeout(1500)
forced = page.evaluate(PANES)
c.ok("unmuting every panel does stop one of them",
any(p["paused"] for p in forced if p["loaded"]), str(forced))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())