#!/usr/bin/env python3 """Picture-in-picture on iOS, where the standard API does not exist. Run against a locally running backend: backend/main.py & .venv/bin/python tests/smoke_ios_pip.py Safari on iPhone and iPad never implemented requestPictureInPicture. It has picture-in-picture -- it just reaches it through WebKit's older presentation-mode switch, and document.pictureInPictureEnabled is undefined, so every capability check answered "no" and the button was hidden on the one platform where people most want it. There is no iPhone here, so the browser is reshaped to have iOS's API surface instead: the standard entry points are deleted and WebKit's are installed. That is enough to test what actually broke, because what broke was which API the code reaches for -- not what the browser does once it is called. """ import sys from playwright.sync_api import sync_playwright BASE = "http://127.0.0.1:5000/" SERVER = "https://hottubapp.io" CHANNEL = "xvideos" SEED = """([server, channel]) => { localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] })); localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } })); localStorage.removeItem('session'); }""" # Runs before any page script, so the app only ever sees the iOS shape. AS_IOS = """(() => { delete Document.prototype.pictureInPictureEnabled; delete Document.prototype.pictureInPictureElement; delete Document.prototype.exitPictureInPicture; delete HTMLVideoElement.prototype.requestPictureInPicture; delete HTMLVideoElement.prototype.disablePictureInPicture; delete window.documentPictureInPicture; window.__pipCalls = []; Object.defineProperty(HTMLVideoElement.prototype, 'webkitPresentationMode', { configurable: true, get() { return this.__mode || 'inline'; }, }); HTMLVideoElement.prototype.webkitSupportsPresentationMode = function() { return true; }; HTMLVideoElement.prototype.webkitSetPresentationMode = function(mode) { window.__pipCalls.push(mode); this.__mode = mode; // WebKit's event, which notably does not bubble. this.dispatchEvent(new Event('webkitpresentationmodechanged')); }; })();""" PINNED = """() => { const slide = document.querySelector('.feed-slide.is-active'); return { calls: window.__pipCalls.slice(), pinned: !!(slide && App.feed.pipPinned(slide)), modes: Array.from(document.querySelectorAll('.feed-video')) .filter(v => v.webkitPresentationMode === 'picture-in-picture').length, video: App.state.feedActiveVideoId, paneVideo: slide ? slide.querySelector('.feed-pane').dataset.videoId : null, }; }""" class Checks: def __init__(self): self.failed = 0 def ok(self, label, condition, detail=""): if not condition: self.failed += 1 print(f" [{'PASS' if condition else 'FAIL'}] {label}" + (f" -- {detail}" if detail and not condition else "")) def open_reels(page): page.goto(BASE, wait_until="domcontentloaded") page.evaluate(SEED, [SERVER, CHANNEL]) page.goto(BASE, wait_until="load") try: page.wait_for_selector(".video-card", timeout=90000) except Exception: page.goto(BASE, wait_until="load") page.wait_for_selector(".video-card", timeout=90000) page.wait_for_timeout(4000) page.evaluate("() => App.feed.toggle()") page.wait_for_selector(".feed-slide", timeout=20000) page.wait_for_timeout(3000) def main(): c = Checks() with sync_playwright() as p: browser = p.chromium.launch(args=[ "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--renderer-process-limit=1", ]) page = browser.new_page(viewport={"width": 430, "height": 930}) page.add_init_script(AS_IOS) open_reels(page) print("\nthe capability check") c.ok("the standard API really is gone", page.evaluate( "() => !document.pictureInPictureEnabled && !window.documentPictureInPicture")) c.ok("picture-in-picture is still reported as available", page.evaluate("() => App.customPlayer.supportsPiP()")) c.ok("the button is not hidden", page.evaluate( """() => { const b = document.querySelector('.feed-slide.is-active .feed-pip-btn'); return !!b && !b.hidden; }""")) print("\nthe button asks WebKit for it") page.click(".feed-slide.is-active .feed-pane .feed-pip-btn") page.wait_for_timeout(1500) entered = page.evaluate(PINNED) c.ok("it called webkitSetPresentationMode", entered["calls"] == ["picture-in-picture"], str(entered["calls"])) c.ok("exactly one video went to the window", entered["modes"] == 1, str(entered["modes"])) # WebKit's event does not bubble, so the feed's document-level listener # only hears about this if the adapter re-fires it. c.ok("the feed noticed and pinned the pane", entered["pinned"], str(entered)) print("\nmoving through the reel from the window") page.evaluate("() => App.feed.pipStep(1)") page.wait_for_timeout(3000) stepped = page.evaluate(PINNED) c.ok("the pinned pane moved to another video", stepped["paneVideo"] != entered["paneVideo"], f"{entered['paneVideo']} -> {stepped['paneVideo']}") c.ok("it is still the video in the window", stepped["pinned"], str(stepped)) c.ok("and still only one", stepped["modes"] == 1, str(stepped["modes"])) print("\nleaving the window") # What iOS does when the reader taps the window's close control. page.evaluate("""() => document.querySelectorAll('.feed-video').forEach((v) => { if (v.webkitPresentationMode === 'picture-in-picture') v.webkitSetPresentationMode('inline'); })""") page.wait_for_timeout(3000) left = page.evaluate(PINNED) c.ok("the pin was released", not left["pinned"], str(left)) c.ok("nothing is left in a window", left["modes"] == 0, str(left["modes"])) c.ok("the feed landed on the video the window ended on", left["video"] == stepped["paneVideo"], f"{stepped['paneVideo']} -> {left['video']}") c.ok("no video is shown twice after the rebuild", page.evaluate( """() => { const ids = Array.from(document.querySelectorAll('.feed-pane')) .map(p => p.dataset.videoId); return ids.length === new Set(ids).size; }""")) print("\nthe button toggles back off") page.click(".feed-slide.is-active .feed-pane .feed-pip-btn") page.wait_for_timeout(1500) page.click(".feed-slide.is-active .feed-pane .feed-pip-btn") page.wait_for_timeout(1500) toggled = page.evaluate(PINNED) c.ok("the second press asked to go back inline", toggled["calls"][-1] == "inline", str(toggled["calls"])) c.ok("nothing is left in a window", toggled["modes"] == 0, str(toggled["modes"])) browser.close() print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed") return 1 if c.failed else 0 if __name__ == "__main__": sys.exit(main())