diff --git a/frontend/js/customPlayer.js b/frontend/js/customPlayer.js index 2b0d8c8..d8e6e7e 100644 --- a/frontend/js/customPlayer.js +++ b/frontend/js/customPlayer.js @@ -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); }; diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 8cf7a63..dfc74d3 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -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)); diff --git a/tests/smoke_ios_pip.py b/tests/smoke_ios_pip.py new file mode 100644 index 0000000..a698abd --- /dev/null +++ b/tests/smoke_ios_pip.py @@ -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())