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:
Simon
2026-09-10 21:49:16 +00:00
parent 2abdc31f56
commit 191c81e55e
3 changed files with 282 additions and 28 deletions

View File

@@ -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));