Reach picture-in-picture the way iOS offers it
Safari on iPhone and iPad has picture-in-picture but never implemented requestPictureInPicture; it exposes WebKit's older presentation-mode switch instead, and document.pictureInPictureEnabled is undefined there. Every capability check in the player and the feed was that one property, so they all answered "no" and the button was hidden outright on the platform where people most want it. The difference is confined to customPlayer: supportsPiP, pipElement, enterPiP and exitPiP speak for both APIs, and bindPiPEvents re-fires WebKit's webkitpresentationmodechanged -- which does not bubble -- as the standard enter/leave events, so the feed's delegated listeners, the pane pin and the media-key stepping work unchanged. Capability is read from the method's presence rather than webkitSupportsPresentationMode(), which answers false until a video track is loaded and would hide the button on preload="none" feed videos. There is no iPhone here, so smoke_ios_pip.py reshapes the browser to iOS's API surface -- deleting the standard entry points and installing WebKit's -- and drives the button through it. That covers what actually broke: which API the code reaches for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
@@ -194,24 +194,107 @@ App.customPlayer = App.customPlayer || {};
|
||||
// tab/app is backgrounded while a video is playing (Safari does this
|
||||
// natively for inline video; Chrome/Android need an explicit call).
|
||||
// -----------------------------------------------------------------
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return !!(document.pictureInPictureEnabled);
|
||||
// iOS has picture-in-picture, but not this API: Safari on iPhone and iPad
|
||||
// never implemented requestPictureInPicture, and exposes WebKit's older
|
||||
// presentation-mode switch instead. document.pictureInPictureEnabled is
|
||||
// undefined there, so every check below it used to answer "no" and the
|
||||
// button was hidden on the one platform where people most want it.
|
||||
//
|
||||
// The difference is confined here. enterPiP/exitPiP/pipElement speak for
|
||||
// both, and bindPiPEvents re-fires WebKit's non-bubbling
|
||||
// webkitpresentationmodechanged as the standard enter/leave events, so the
|
||||
// delegated listeners elsewhere work unchanged.
|
||||
const WEBKIT_PIP = 'picture-in-picture';
|
||||
let webkitPipElement = null;
|
||||
|
||||
const standardPiP = () => !!document.pictureInPictureEnabled;
|
||||
const webkitPiP = (video) => !!(video && typeof video.webkitSetPresentationMode === 'function');
|
||||
|
||||
// No element to ask about: does this browser have either API at all?
|
||||
let webkitProbe = null;
|
||||
const webkitAvailable = function() {
|
||||
if (webkitProbe === null) {
|
||||
webkitProbe = typeof HTMLVideoElement !== 'undefined' &&
|
||||
(typeof HTMLVideoElement.prototype.webkitSetPresentationMode === 'function' ||
|
||||
webkitPiP(document.createElement('video')));
|
||||
}
|
||||
return webkitProbe;
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
|
||||
try {
|
||||
if (document.pictureInPictureElement === video) {
|
||||
await document.exitPictureInPicture();
|
||||
} else {
|
||||
// Deliberately the method's presence rather than
|
||||
// video.webkitSupportsPresentationMode(): that answers false until a video
|
||||
// track is loaded, and feed videos are preload="none" until they go active
|
||||
// -- it would hide the button on exactly the videos about to be able to
|
||||
// use it.
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return standardPiP() || webkitAvailable();
|
||||
};
|
||||
|
||||
App.customPlayer.pipElement = function() {
|
||||
return document.pictureInPictureElement || webkitPipElement || null;
|
||||
};
|
||||
|
||||
App.customPlayer.enterPiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (standardPiP()) {
|
||||
if (video.disablePictureInPicture) return false;
|
||||
try {
|
||||
await video.requestPictureInPicture();
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!webkitPiP(video)) return false;
|
||||
try {
|
||||
// Synchronous, and it needs the user gesture that got us here.
|
||||
video.webkitSetPresentationMode(WEBKIT_PIP);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
App.customPlayer.exitPiP = async function() {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const video = webkitPipElement;
|
||||
if (!webkitPiP(video)) return;
|
||||
try { video.webkitSetPresentationMode('inline'); } catch (err) { /* already gone */ }
|
||||
};
|
||||
|
||||
App.customPlayer.bindPiPEvents = function(video) {
|
||||
if (standardPiP() || !webkitPiP(video)) return function destroy() {};
|
||||
// The same event announces fullscreen and inline, so only a real change
|
||||
// in picture-in-picture-ness is worth reporting.
|
||||
let wasPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
const onChange = function() {
|
||||
const isPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
if (isPip === wasPip) return;
|
||||
wasPip = isPip;
|
||||
if (isPip) webkitPipElement = video;
|
||||
else if (webkitPipElement === video) webkitPipElement = null;
|
||||
video.dispatchEvent(new CustomEvent(
|
||||
isPip ? 'enterpictureinpicture' : 'leavepictureinpicture', { bubbles: true }));
|
||||
};
|
||||
video.addEventListener('webkitpresentationmodechanged', onChange);
|
||||
return function destroy() {
|
||||
video.removeEventListener('webkitpresentationmodechanged', onChange);
|
||||
if (webkitPipElement === video) webkitPipElement = null;
|
||||
};
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (App.customPlayer.pipElement() === video) {
|
||||
await App.customPlayer.exitPiP();
|
||||
return true;
|
||||
}
|
||||
return App.customPlayer.enterPiP(video);
|
||||
};
|
||||
|
||||
// Asking for picture-in-picture the moment a tab is hidden is a request
|
||||
// with no user gesture behind it, and browsers refuse those -- which is why
|
||||
// the imperative call below fails silently. `autoPictureInPicture` is the
|
||||
@@ -230,17 +313,19 @@ App.customPlayer = App.customPlayer || {};
|
||||
App.customPlayer.bindAutoPiP = function(video) {
|
||||
if (!video) return function destroy() {};
|
||||
App.customPlayer.setAutoPiP(video, true);
|
||||
const unbindEvents = App.customPlayer.bindPiPEvents(video);
|
||||
const trigger = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
|
||||
if (document.pictureInPictureElement) return;
|
||||
if (!App.customPlayer.supportsPiP() || video.disablePictureInPicture) return;
|
||||
if (App.customPlayer.pipElement()) return;
|
||||
if (video.paused || video.ended) return;
|
||||
video.requestPictureInPicture().catch(() => {});
|
||||
App.customPlayer.enterPiP(video);
|
||||
};
|
||||
document.addEventListener('visibilitychange', trigger);
|
||||
window.addEventListener('pagehide', trigger);
|
||||
return function destroy() {
|
||||
App.customPlayer.setAutoPiP(video, false);
|
||||
unbindEvents();
|
||||
document.removeEventListener('visibilitychange', trigger);
|
||||
window.removeEventListener('pagehide', trigger);
|
||||
};
|
||||
|
||||
@@ -287,6 +287,10 @@ App.feed = App.feed || {};
|
||||
wakeHud();
|
||||
};
|
||||
|
||||
// On iOS these are the only source of enter/leave events; everywhere
|
||||
// else this is a no-op and the browser fires them itself.
|
||||
cleanups.push(App.customPlayer.bindPiPEvents(video));
|
||||
|
||||
const pipBtn = pane.querySelector('.feed-pip-btn');
|
||||
if (pipBtn) {
|
||||
pipBtn.hidden = !App.customPlayer.supportsPiP() && !App.feed.docPipSupported();
|
||||
@@ -296,8 +300,8 @@ App.feed = App.feed || {};
|
||||
App.feed.closeDocPip();
|
||||
return;
|
||||
}
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture().catch(() => {});
|
||||
if (App.customPlayer.pipElement()) {
|
||||
await App.customPlayer.exitPiP();
|
||||
return;
|
||||
}
|
||||
// The whole feed in a real window beats one pane's frames in a
|
||||
@@ -1183,7 +1187,7 @@ App.feed = App.feed || {};
|
||||
// the rest of the session, so any leave that finds nothing left in
|
||||
// a window releases it -- being wrong here costs a rebuild, being
|
||||
// stuck costs a frozen feed.
|
||||
if (pane && pane !== pipPane && document.pictureInPictureElement) return;
|
||||
if (pane && pane !== pipPane && App.customPlayer.pipElement()) return;
|
||||
const landed = (pipPane.dataset && pipPane.dataset.videoId) || null;
|
||||
pipPane = null;
|
||||
unbindMediaSession();
|
||||
@@ -1199,17 +1203,12 @@ App.feed = App.feed || {};
|
||||
};
|
||||
|
||||
App.feed.openPip = async function(pane) {
|
||||
if (!document.pictureInPictureEnabled) return false;
|
||||
if (!App.customPlayer.supportsPiP()) return false;
|
||||
const target = pane || (slidesByIndex.get(state.feedActiveIndex) &&
|
||||
panesOf(slidesByIndex.get(state.feedActiveIndex))[0]);
|
||||
const video = target && target.querySelector('.feed-video');
|
||||
if (!video || video.disablePictureInPicture) return false;
|
||||
try {
|
||||
await video.requestPictureInPicture();
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
return App.customPlayer.enterPiP(video);
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
@@ -1312,8 +1311,8 @@ App.feed = App.feed || {};
|
||||
if (!root || !state.feedOpen) return false;
|
||||
// Two picture-in-picture windows cannot both hold this feed, and the
|
||||
// video one holds an element that is about to move.
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture().catch(() => {});
|
||||
if (App.customPlayer.pipElement()) {
|
||||
await App.customPlayer.exitPiP();
|
||||
}
|
||||
let win;
|
||||
try {
|
||||
@@ -1382,14 +1381,14 @@ App.feed = App.feed || {};
|
||||
const onFeedHidden = function() {
|
||||
if (!state.feedOpen) return;
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
if (!document.pictureInPictureEnabled) return;
|
||||
if (document.pictureInPictureElement) return;
|
||||
if (!App.customPlayer.supportsPiP()) return;
|
||||
if (App.customPlayer.pipElement()) return;
|
||||
const video = autoPipVideo();
|
||||
if (!video || video.paused || video.ended || video.disablePictureInPicture) return;
|
||||
// The browser may already be doing this itself, from the attribute
|
||||
// updateAutoPiPTarget put on this very element; the request is only for
|
||||
// where the attribute is ignored but a request would be allowed.
|
||||
video.requestPictureInPicture().catch(() => {});
|
||||
App.customPlayer.enterPiP(video);
|
||||
};
|
||||
|
||||
let autoPipBound = false;
|
||||
@@ -1516,8 +1515,8 @@ App.feed = App.feed || {};
|
||||
setHudIdle(false);
|
||||
updateAutoPiPTarget(); // feedOpen is false now, so this clears them
|
||||
unbindMediaSession();
|
||||
if (pipPane && document.pictureInPictureElement) {
|
||||
document.exitPictureInPicture().catch(() => {});
|
||||
if (pipPane && App.customPlayer.pipElement()) {
|
||||
App.customPlayer.exitPiP();
|
||||
}
|
||||
pipPane = null;
|
||||
slidesByIndex.forEach((slide) => panesOf(slide).forEach(destroySlidePlayback));
|
||||
|
||||
170
tests/smoke_ios_pip.py
Normal file
170
tests/smoke_ios_pip.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user