#!/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())