A video picture-in-picture window renders one <video>'s frames and nothing else, which is why moving through the reel from it had to be smuggled in through media keys. Document picture-in-picture opens a real document instead, so the feed is moved into it: #feed-view and its whole subtree are appended to the new window's body. Nothing is copied and no <video> is re-created, so playback continues across the move and the scroll-snap list, the panes, the split tree and every control keep working -- they are the same elements, in another window. Scrolling the window scrolls the reel, because it is the reel. The lookups in feed.js now go through the cached #feed-view root rather than document.getElementById, since after the move the feed is no longer in this document, and the HUD-idle class rides on that root so it travels with it. The page underneath drops back to the grid while the feed is out, and gets it back when the window closes. The video window stays as the fallback: it is Chrome-only, needs a user gesture, and a hidden tab has none -- so auto-PiP on tab switch is still the old path. It is suppressed while a document window is open, or the browser would tear the video out of it on the next switch. Also fixes the picture-in-picture button, which referenced an undefined `pane` in bindSharedControls (the parameter was named `slide`) and threw into a swallowed promise rejection on every click. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
176 lines
7.6 KiB
Python
176 lines
7.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Document picture-in-picture: the reel, in a window, scrolling.
|
|
|
|
Run against a locally running backend:
|
|
|
|
backend/main.py &
|
|
.venv/bin/python tests/smoke_docpip.py
|
|
|
|
A video picture-in-picture window renders one <video>'s frames and cannot
|
|
scroll. Document picture-in-picture opens a real document instead, so the feed
|
|
is *moved* into it -- the same elements, another window. That is what these
|
|
checks are about: the move must be a move (no rebuild, playback intact), the
|
|
window must scroll the reel for real, and closing it must put the feed back
|
|
where it came from.
|
|
"""
|
|
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');
|
|
}"""
|
|
|
|
# The feed can be read from either document. Only App.state lives in the page --
|
|
# the window's document has no scripts of its own; it holds the moved elements,
|
|
# whose handlers are still the page's closures. That is the whole design, so the
|
|
# probe takes the document to look in and always runs in the page.
|
|
PROBE = """(where) => {
|
|
const doc = where === 'pip'
|
|
? (documentPictureInPicture.window && documentPictureInPicture.window.document)
|
|
: document;
|
|
if (!doc) return null;
|
|
const root = doc.getElementById('feed-view');
|
|
const active = doc.querySelector('.feed-slide.is-active');
|
|
const scroller = doc.getElementById('feed-scroll');
|
|
return {
|
|
rooted_here: !!root,
|
|
slides: doc.querySelectorAll('.feed-slide').length,
|
|
step: App.state.feedActiveIndex,
|
|
video: App.state.feedActiveVideoId,
|
|
playing: active ? Array.from(active.querySelectorAll('.feed-video'))
|
|
.map(v => !v.paused && v.readyState >= 2) : null,
|
|
scrollable: scroller ? scroller.scrollHeight > scroller.clientHeight + 1 : false,
|
|
unique: (() => {
|
|
const ids = Array.from(doc.querySelectorAll('.feed-pane')).map(p => p.dataset.videoId);
|
|
return ids.length === new Set(ids).size;
|
|
})(),
|
|
};
|
|
}"""
|
|
|
|
|
|
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",
|
|
])
|
|
context = browser.new_context(viewport={"width": 1400, "height": 1000})
|
|
page = context.new_page()
|
|
open_reels(page)
|
|
|
|
c.ok("this browser offers document picture-in-picture",
|
|
page.evaluate("() => App.feed.docPipSupported()"))
|
|
|
|
before = page.evaluate(PROBE, 'page')
|
|
c.ok("the feed starts in the page", before["rooted_here"])
|
|
c.ok("and is scrollable there", before["scrollable"])
|
|
|
|
print("\nthe picture-in-picture button opens a window")
|
|
# A click, not an evaluate: requestWindow needs a user gesture, which is
|
|
# exactly the reason the tab-switch path cannot use this API.
|
|
with context.expect_page(timeout=15000) as caught:
|
|
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
|
pip = caught.value
|
|
pip.wait_for_timeout(3000)
|
|
|
|
c.ok("the feed reports a window open", page.evaluate("() => App.feed.docPipOpen()"))
|
|
c.ok("the feed left the page", not page.evaluate(
|
|
"() => !!document.getElementById('feed-view')"))
|
|
c.ok("the page underneath is usable again", page.evaluate(
|
|
"() => document.body.style.overflow === 'auto'"))
|
|
|
|
moved = page.evaluate(PROBE, 'pip')
|
|
c.ok("the feed is in the window", moved["rooted_here"], str(moved))
|
|
c.ok("its slides came with it", moved["slides"] > 0, str(moved["slides"]))
|
|
# The point of moving rather than rebuilding: the <video> elements are
|
|
# the same ones, so nothing reloads and nothing stops.
|
|
c.ok("playback survived the move", moved["playing"] and all(moved["playing"]),
|
|
str(moved["playing"]))
|
|
c.ok("it landed on the same video", moved["video"] == before["video"],
|
|
f"{before['video']} -> {moved['video']}")
|
|
|
|
print("\nthe window scrolls the reel")
|
|
c.ok("the window's feed is scrollable", moved["scrollable"], str(moved))
|
|
pip.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
|
" s.scrollTop += s.clientHeight; }")
|
|
pip.wait_for_timeout(3500)
|
|
scrolled = page.evaluate(PROBE, 'pip')
|
|
c.ok("scrolling moved to the next step", scrolled["step"] == moved["step"] + 1,
|
|
f"{moved['step']} -> {scrolled['step']}")
|
|
c.ok("and to another video", scrolled["video"] != moved["video"],
|
|
f"{moved['video']} -> {scrolled['video']}")
|
|
c.ok("the new step is playing", scrolled["playing"] and all(scrolled["playing"]),
|
|
str(scrolled["playing"]))
|
|
|
|
print("\nclosing the window brings the feed home")
|
|
pip.close()
|
|
page.wait_for_timeout(3500)
|
|
back = page.evaluate(PROBE, 'page')
|
|
c.ok("no window is open", not page.evaluate("() => App.feed.docPipOpen()"))
|
|
c.ok("the feed is in the page again", back["rooted_here"], str(back))
|
|
c.ok("reels is still open", page.evaluate("() => App.feed.isOpen()"))
|
|
c.ok("the page is back in reels mode", page.evaluate(
|
|
"() => document.body.classList.contains('feed-mode-open')"))
|
|
# The window was scrolled while it was out; coming back must not undo it.
|
|
c.ok("it kept where the window left off", back["video"] == scrolled["video"],
|
|
f"{scrolled['video']} -> {back['video']}")
|
|
c.ok("the feed still scrolls here", back["scrollable"], str(back))
|
|
c.ok("no video was duplicated by the round trip", back["unique"])
|
|
|
|
print("\nleaving reels while the window is open")
|
|
with context.expect_page(timeout=15000) as caught2:
|
|
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
|
pip2 = caught2.value
|
|
pip2.wait_for_timeout(2500)
|
|
page.evaluate("() => App.feed.close()")
|
|
page.wait_for_timeout(2000)
|
|
c.ok("closing reels closes the window", pip2.is_closed() or
|
|
not page.evaluate("() => App.feed.docPipOpen()"))
|
|
c.ok("the feed came back before it was torn down", page.evaluate(
|
|
"() => !!document.getElementById('feed-view')"))
|
|
c.ok("the page is scrollable again", page.evaluate(
|
|
"() => document.body.style.overflow === 'auto'"))
|
|
|
|
context.close()
|
|
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())
|