Put the reel itself in the picture-in-picture window, so it scrolls

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
This commit is contained in:
Simon
2026-09-10 20:45:49 +00:00
parent 76a078b9b5
commit 2abdc31f56
3 changed files with 397 additions and 36 deletions

View File

@@ -2237,7 +2237,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
} }
/* The HUD's idle fade takes the panel tools with it, like every other control. */ /* The HUD's idle fade takes the panel tools with it, like every other control. */
body.feed-hud-idle .feed-pane-tools { .feed-hud-idle .feed-pane-tools {
opacity: 0; opacity: 0;
transition: opacity 0.4s ease; transition: opacity 0.4s ease;
} }
@@ -2464,13 +2464,13 @@ body.feed-hud-idle .feed-pane-tools {
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay /* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
interactive (pointer-events untouched) so the buttons keep working while interactive (pointer-events untouched) so the buttons keep working while
invisible; any pointer/scroll activity reveals them again (see App.feed). */ invisible; any pointer/scroll activity reveals them again (see App.feed). */
body.feed-hud-idle .feed-info, .feed-hud-idle .feed-info,
body.feed-hud-idle .feed-timeline, .feed-hud-idle .feed-timeline,
body.feed-hud-idle .feed-mute-btn, .feed-hud-idle .feed-mute-btn,
body.feed-hud-idle .feed-fav-btn, .feed-hud-idle .feed-fav-btn,
body.feed-hud-idle .feed-pip-btn, .feed-hud-idle .feed-pip-btn,
body.feed-hud-idle .feed-format-btn, .feed-hud-idle .feed-format-btn,
body.feed-hud-idle .mode-toggle-btn { .feed-hud-idle .mode-toggle-btn {
opacity: 0; opacity: 0;
} }

View File

@@ -48,30 +48,63 @@ App.feed = App.feed || {};
let hudIdleTimer = null; let hudIdleTimer = null;
let hudActivityBound = false; let hudActivityBound = false;
// Everything the feed owns hangs off #feed-view, and the whole subtree can
// be moved into a document picture-in-picture window (see openDocPip). So
// it is looked up once and kept: after the move it is no longer in this
// document, and getElementById would return nothing.
let feedRoot = null;
const getRoot = function() {
if (!feedRoot || !feedRoot.isConnected) feedRoot = document.getElementById('feed-view') || feedRoot;
return feedRoot;
};
// The document the feed currently lives in -- this one, or the picture-in-
// picture window's.
const feedDocument = function() {
const root = getRoot();
return (root && root.ownerDocument) || document;
};
// The HUD-idle class goes on both the feed root and the body: the root so it
// travels into the picture-in-picture window, the body for .mode-toggle-btn,
// which sits outside the feed and stays behind.
const setHudIdle = function(on) {
const root = getRoot();
if (root) root.classList.toggle('feed-hud-idle', on);
document.body.classList.toggle('feed-hud-idle', on);
};
const scheduleHudHide = function() { const scheduleHudHide = function() {
if (hudIdleTimer) clearTimeout(hudIdleTimer); if (hudIdleTimer) clearTimeout(hudIdleTimer);
hudIdleTimer = setTimeout(() => { hudIdleTimer = setTimeout(() => {
hudIdleTimer = null; hudIdleTimer = null;
if (!state.feedOpen) return; if (!state.feedOpen) return;
document.body.classList.add('feed-hud-idle'); setHudIdle(true);
// The quality menu only fades with the rest of the HUD if we close // The quality menu only fades with the rest of the HUD if we close
// it: it's an opened popover, not a permanently mounted control. // it: it's an opened popover, not a permanently mounted control.
document.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; }); const root = getRoot();
if (root) root.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
}, HUD_IDLE_MS); }, HUD_IDLE_MS);
}; };
const wakeHud = function() { const wakeHud = function() {
document.body.classList.remove('feed-hud-idle'); setHudIdle(false);
if (state.feedOpen) scheduleHudHide(); if (state.feedOpen) scheduleHudHide();
}; };
const getScroller = () => document.getElementById('feed-scroll'); const inRoot = function(id) {
const getSentinel = () => document.getElementById('feed-sentinel'); const root = getRoot();
const getTopSpacer = () => document.getElementById('feed-top-spacer'); return root ? root.querySelector(`#${id}`) : null;
};
const getScroller = () => inRoot('feed-scroll');
const getSentinel = () => inRoot('feed-sentinel');
const getTopSpacer = () => inRoot('feed-top-spacer');
const slideHeight = function() { const slideHeight = function() {
const scroller = getScroller(); const scroller = getScroller();
return (scroller && scroller.clientHeight) || window.innerHeight || 1; const view = feedDocument().defaultView || window;
return (scroller && scroller.clientHeight) || view.innerHeight || 1;
}; };
// Steps, not videos: with N panes a step covers N videos at once. // Steps, not videos: with N panes a step covers N videos at once.
@@ -243,26 +276,33 @@ App.feed = App.feed || {};
// identically. Feed's own timeline/favorite/title and scroll-snap // identically. Feed's own timeline/favorite/title and scroll-snap
// slide-to-slide navigation are untouched (see bindTimeline above and // slide-to-slide navigation are untouched (see bindTimeline above and
// setActive/onScroll below). // setActive/onScroll below).
const bindSharedControls = function(slide, video, videoData) { const bindSharedControls = function(pane, video, videoData) {
const cleanups = []; const cleanups = [];
const escalator = App.customPlayer.createSkipEscalator(); const escalator = App.customPlayer.createSkipEscalator();
cleanups.push(() => escalator.destroy()); cleanups.push(() => escalator.destroy());
const doSkip = (direction) => { const doSkip = (direction) => {
const amount = App.customPlayer.skip(video, direction, escalator); const amount = App.customPlayer.skip(video, direction, escalator);
flashFeed(slide, `${direction === 'forward' ? '+' : '-'}${amount}s`); flashFeed(pane, `${direction === 'forward' ? '+' : '-'}${amount}s`);
wakeHud(); wakeHud();
}; };
const pipBtn = slide.querySelector('.feed-pip-btn'); const pipBtn = pane.querySelector('.feed-pip-btn');
if (pipBtn) { if (pipBtn) {
pipBtn.hidden = !App.customPlayer.supportsPiP(); pipBtn.hidden = !App.customPlayer.supportsPiP() && !App.feed.docPipSupported();
const onClick = async (event) => { const onClick = async (event) => {
event.stopPropagation(); event.stopPropagation();
if (App.feed.docPipOpen()) {
App.feed.closeDocPip();
return;
}
if (document.pictureInPictureElement) { if (document.pictureInPictureElement) {
await document.exitPictureInPicture().catch(() => {}); await document.exitPictureInPicture().catch(() => {});
return; return;
} }
// The whole feed in a real window beats one pane's frames in a
// video window: it scrolls, and every control comes with it.
if (await App.feed.openDocPip()) return;
await App.feed.openPip(pane); await App.feed.openPip(pane);
}; };
pipBtn.addEventListener('click', onClick); pipBtn.addEventListener('click', onClick);
@@ -272,15 +312,15 @@ App.feed = App.feed || {};
// exist, so binding every pane makes them race and the winner arbitrary. // exist, so binding every pane makes them race and the winner arbitrary.
// The feed picks one deliberately -- see updateAutoPiPTarget. // The feed picks one deliberately -- see updateAutoPiPTarget.
const formatBtn = slide.querySelector('.feed-format-btn'); const formatBtn = pane.querySelector('.feed-format-btn');
const formatMenu = slide.querySelector('.feed-format-menu'); const formatMenu = pane.querySelector('.feed-format-menu');
const onFormatPick = (fmt) => { const onFormatPick = (fmt) => {
slide._formatOverride = fmt; pane._formatOverride = fmt;
const t = video.currentTime; const t = video.currentTime;
if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t); if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t);
// Tear down the current source (mirrors destroySlidePlayback's // Tear down the current source (mirrors destroySlidePlayback's
// hls/video reset) before reloading with the new format -- this // hls/video reset) before reloading with the new format -- this
// is a live in-place reload, not a fresh never-loaded slide, so // is a live in-place reload, not a fresh never-loaded pane, so
// the old Hls.js instance must be destroyed or it keeps running // the old Hls.js instance must be destroyed or it keeps running
// (fetching segments, attached to the same <video>) forever. // (fetching segments, attached to the same <video>) forever.
if (video._hlsPlayer) { if (video._hlsPlayer) {
@@ -291,35 +331,35 @@ App.feed = App.feed || {};
video.pause(); video.pause();
video.removeAttribute('src'); video.removeAttribute('src');
video.load(); video.load();
slide.classList.remove('is-loaded'); pane.classList.remove('is-loaded');
loadSlideSource(slide, videoData, true); loadSlideSource(pane, videoData, true);
}; };
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick, const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
{ getCurrentUrl: () => slide._activeUrl || '', onOpen: wakeHud }); { getCurrentUrl: () => pane._activeUrl || '', onOpen: wakeHud });
let destroyFormatMenu = bindFormats(); let destroyFormatMenu = bindFormats();
cleanups.push(() => destroyFormatMenu()); cleanups.push(() => destroyFormatMenu());
// A slide can go active before its formats have been resolved (feed items // A pane can go active before its formats have been resolved (feed items
// carry only a page URL until then), which would leave the quality menu // carry only a page URL until then), which would leave the quality menu
// empty. Playback already runs from that page URL through the proxy, so // empty. Playback already runs from that page URL through the proxy, so
// resolve in the background and rebuild the menu once the real qualities // resolve in the background and rebuild the menu once the real qualities
// land -- same as the standalone player does. // land -- same as the standalone player does.
if (App.videos && typeof App.videos.ensureFormats === 'function') { if (App.videos && typeof App.videos.ensureFormats === 'function') {
App.videos.ensureFormats(videoData).then((meta) => { App.videos.ensureFormats(videoData).then((meta) => {
// Bail if the slide was torn down (or rebound) in the meantime. // Bail if the pane was torn down (or rebound) in the meantime.
if (!meta || slide._sharedControlCleanups !== cleanups) return; if (!meta || pane._sharedControlCleanups !== cleanups) return;
destroyFormatMenu(); destroyFormatMenu();
destroyFormatMenu = bindFormats(); destroyFormatMenu = bindFormats();
}); });
} }
cleanups.push(App.customPlayer.attachGestures(slide, { cleanups.push(App.customPlayer.attachGestures(pane, {
onSingleTap: wakeHud, onSingleTap: wakeHud,
onDoubleTapLeft: () => doSkip('back'), onDoubleTapLeft: () => doSkip('back'),
onDoubleTapRight: () => doSkip('forward'), onDoubleTapRight: () => doSkip('forward'),
ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline' ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline'
})); }));
slide._sharedControlCleanups = cleanups; pane._sharedControlCleanups = cleanups;
}; };
// The tallest rendition worth decoding for this pane: its own height in // The tallest rendition worth decoding for this pane: its own height in
@@ -1003,6 +1043,12 @@ App.feed = App.feed || {};
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Picture-in-picture that can be moved through // Picture-in-picture that can be moved through
// //
// This is the fallback path. Where document picture-in-picture exists (see
// openDocPip below) the feed itself goes into the window and scrolls; this
// is what is left when it doesn't -- and it is still the only path the
// browser can take on its own when the tab is hidden, since opening a
// document window needs a user gesture and a tab switch has none.
//
// A picture-in-picture window shows one <video>'s frames and nothing else: // A picture-in-picture window shows one <video>'s frames and nothing else:
// no markup, no scrolling. What it does offer is the media controls the // no markup, no scrolling. What it does offer is the media controls the
// page declares through the Media Session API, so "scroll the reel from // page declares through the Media Session API, so "scroll the reel from
@@ -1166,6 +1212,133 @@ App.feed = App.feed || {};
} }
}; };
// ------------------------------------------------------------------
// A picture-in-picture window you can actually scroll
//
// Everything above works around the fact that a video picture-in-picture
// window renders frames and nothing else -- no markup, so no scrolling, and
// moving through the reel has to be smuggled in through media keys.
//
// Document picture-in-picture lifts that restriction: it opens an empty
// always-on-top window with a real document in it. So instead of rebuilding
// the feed there, the feed is *moved* there -- #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, just in another window. Scrolling
// the window scrolls the reel, because it is the reel.
//
// The lookups in this file go through getRoot() rather than
// document.getElementById for exactly this reason: after the move the feed
// is no longer in this document.
//
// Chrome-only for now (Firefox and Safari have no documentPictureInPicture),
// and it needs a user gesture, so the tab-switch path stays with the video
// window above. Closing the window moves the feed back.
// ------------------------------------------------------------------
let pipWindow = null;
const docPipSupported = function() {
return !!(window.documentPictureInPicture && window.documentPictureInPicture.requestWindow);
};
App.feed.docPipSupported = docPipSupported;
App.feed.docPipOpen = () => !!pipWindow;
// The window starts as a blank document with no styles at all. Cloning this
// page's <link>s and <style>s is what makes the moved feed look like itself;
// the hrefs read off the property are already absolute.
const adoptStyles = function(win) {
document.querySelectorAll('link[rel="stylesheet"], style').forEach((node) => {
win.document.head.appendChild(node.cloneNode(true));
});
const base = win.document.createElement('style');
base.textContent = 'html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#000;}';
win.document.head.appendChild(base);
};
// Keep the shape the feed already has, at a size that suits a floating
// window -- a single pane is portrait, a row split is wide, and the browser
// clamps whatever it cannot honour.
const pipWindowSize = function(root) {
const w = root.clientWidth || window.innerWidth || 360;
const h = root.clientHeight || window.innerHeight || 640;
const height = Math.max(240, Math.min(720, Math.round(window.screen.availHeight * 0.6)));
return { width: Math.max(200, Math.round(height * (w / h))), height: height };
};
// The move changes the viewport the slides are sized against, so scroll
// offsets measured in pixels are meaningless afterwards. realignToActive
// recomputes them from the active video's id -- the same thing an
// orientation change does.
const settleAfterMove = function() {
suppressScroll = true;
realignToActive();
requestAnimationFrame(() => {
realignToActive();
requestAnimationFrame(() => {
suppressScroll = false;
const slide = slidesByIndex.get(state.feedActiveIndex);
if (slide) panesOf(slide).forEach((pane) => {
const video = pane.querySelector('.feed-video');
if (video && video.paused && pane.classList.contains('is-loaded')) {
video.play().catch(() => {});
}
});
});
});
};
// Puts the feed back in the page. Called when the window closes, however it
// closed -- its own button, the feed closing, or the tab going away.
const undockFeed = function() {
const root = getRoot();
pipWindow = null;
if (!root || root.ownerDocument === document) return;
document.body.appendChild(root);
if (state.feedOpen) {
document.body.classList.add('feed-mode-open');
document.body.style.overflow = 'hidden';
settleAfterMove();
App.feed.updateToggleButton();
} else {
App.feed.close();
}
};
App.feed.openDocPip = async function() {
if (!docPipSupported() || pipWindow) return false;
const root = getRoot();
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(() => {});
}
let win;
try {
win = await window.documentPictureInPicture.requestWindow(pipWindowSize(root));
} catch (err) {
return false;
}
pipWindow = win;
adoptStyles(win);
win.document.body.appendChild(root); // adopts the live subtree, playback and all
// The page underneath is usable again: the grid is right there while the
// reel plays in the window.
document.body.classList.remove('feed-mode-open');
document.body.style.overflow = 'auto';
win.addEventListener('pagehide', undockFeed);
win.addEventListener('resize', onResize);
settleAfterMove();
wakeHud();
return true;
};
App.feed.closeDocPip = function() {
if (pipWindow) pipWindow.close(); // fires pagehide -> undockFeed
};
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Auto picture-in-picture // Auto picture-in-picture
// //
@@ -1176,6 +1349,10 @@ App.feed = App.feed || {};
// muted. // muted.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
const autoPipVideo = function() { const autoPipVideo = function() {
// Already out of the tab, in a better window. Marking a video here would
// have the browser tear it out of the document window on the next tab
// switch and replace a scrollable reel with one video's frames.
if (pipWindow) return null;
const slide = slidesByIndex.get(state.feedActiveIndex); const slide = slidesByIndex.get(state.feedActiveIndex);
if (!slide) return null; if (!slide) return null;
const panes = panesOf(slide); const panes = panesOf(slide);
@@ -1262,7 +1439,7 @@ App.feed = App.feed || {};
}; };
App.feed.open = function(startVideoId) { App.feed.open = function(startVideoId) {
const container = document.getElementById('feed-view'); const container = getRoot();
const scroller = getScroller(); const scroller = getScroller();
if (!container || !scroller) return; if (!container || !scroller) return;
state.feedOpen = true; state.feedOpen = true;
@@ -1320,14 +1497,23 @@ App.feed = App.feed || {};
}; };
App.feed.close = function() { App.feed.close = function() {
const container = document.getElementById('feed-view'); const container = getRoot();
if (!container) return; if (!container) return;
state.feedOpen = false; state.feedOpen = false;
// Bring the feed home before tearing it down, and do the move here
// rather than waiting for the window's pagehide -- that fires later, and
// undockFeed would call straight back into this function.
if (pipWindow) {
const win = pipWindow;
pipWindow = null;
document.body.appendChild(container);
win.close();
}
if (hudIdleTimer) { if (hudIdleTimer) {
clearTimeout(hudIdleTimer); clearTimeout(hudIdleTimer);
hudIdleTimer = null; hudIdleTimer = null;
} }
document.body.classList.remove('feed-hud-idle'); setHudIdle(false);
updateAutoPiPTarget(); // feedOpen is false now, so this clears them updateAutoPiPTarget(); // feedOpen is false now, so this clears them
unbindMediaSession(); unbindMediaSession();
if (pipPane && document.pictureInPictureElement) { if (pipPane && document.pictureInPictureElement) {
@@ -1381,14 +1567,14 @@ App.feed = App.feed || {};
}; };
App.feed.updateMuteButton = function() { App.feed.updateMuteButton = function() {
const icon = document.getElementById('feed-mute-icon'); const icon = inRoot('feed-mute-icon');
if (!icon) return; if (!icon) return;
icon.src = state.feedMuted icon.src = state.feedMuted
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg' ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg'; : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
icon.alt = state.feedMuted ? 'Unmute' : 'Mute'; icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
// Pulse a brass ring while muted to hint "tap to hear sound". // Pulse a brass ring while muted to hint "tap to hear sound".
const btn = document.getElementById('feed-mute-btn'); const btn = inRoot('feed-mute-btn');
if (btn) btn.classList.toggle('is-muted', !!state.feedMuted); if (btn) btn.classList.toggle('is-muted', !!state.feedMuted);
}; };
})(); })();

175
tests/smoke_docpip.py Normal file
View File

@@ -0,0 +1,175 @@
#!/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())