From 76a078b9b5e41a5f336e88556628bc913d743c79 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 10 Sep 2026 20:31:32 +0000 Subject: [PATCH] Move through the reel from the picture-in-picture window A picture-in-picture window shows one video's frames and nothing else -- no markup, no scrolling. What it does have is the controls the page declares through the Media Session API, so moving through the reel from the window means its next and previous buttons. Those are the same controls that appear on a lock screen, on headphones and on media keys, and with no window open they move the feed itself. The window rides the pane's own video. That matters for the automatic case, and it is where the first attempt went wrong: a hidden stand-in element was used so the window could survive the feed advancing, but a browser only carries a video out to a window by itself if that video is already playing, and a hidden one never is. So opening it by hand worked -- a click is a gesture, and an explicit request needs one -- while switching tabs could not, and the autoPictureInPicture attribute meant to handle that case was sitting on a different element than the code used. Both halves now point at the same video. Stepping is what a pane's video normally cannot survive, since the feed destroys a step's videos when it moves on. While a pane is in the window it is pinned against teardown and its source is swapped in place: the pane becomes the video being watched, title and poster included, and the feed is rebuilt around wherever it ended when the window closes. Two panes can name the same video while that is going on; the rebuild puts the steps back in order. The leave handler releases the pin on any leave that finds nothing left in a window, rather than insisting the element match exactly. The two ways of being wrong are not the same size: releasing too readily costs a rebuild nobody sees, while a pin that never releases leaves the feed unable to recycle that slide for the rest of the session. Whether a window opens on a tab switch is browser policy, not ours: Safari honours the attribute, Chrome honours it for installed apps. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd --- frontend/js/feed.js | 185 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 183 insertions(+), 2 deletions(-) diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 98dfca2..1234e8a 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -259,7 +259,11 @@ App.feed = App.feed || {}; pipBtn.hidden = !App.customPlayer.supportsPiP(); const onClick = async (event) => { event.stopPropagation(); - await App.customPlayer.togglePiP(video); + if (document.pictureInPictureElement) { + await document.exitPictureInPicture().catch(() => {}); + return; + } + await App.feed.openPip(pane); }; pipBtn.addEventListener('click', onClick); cleanups.push(() => pipBtn.removeEventListener('click', onClick)); @@ -752,6 +756,8 @@ App.feed = App.feed || {}; const removeSlide = function(index) { const slide = slidesByIndex.get(index); if (!slide) return; + // Removing the element in the picture-in-picture window closes it. + if (pipPinned(slide)) return; teardownSlide(slide); slide.remove(); slidesByIndex.delete(index); @@ -922,7 +928,7 @@ App.feed = App.feed || {}; // Recently-watched panes stay loaded but paused so scrolling // back resumes seamlessly from where it was paused. if (pane.classList.contains('is-loaded')) pauseSlide(pane); - } else if (pane.classList.contains('is-loaded')) { + } else if (pane.classList.contains('is-loaded') && pane !== pipPane) { destroySlidePlayback(pane); } }); @@ -994,6 +1000,172 @@ App.feed = App.feed || {}; }); }; + // ------------------------------------------------------------------ + // Picture-in-picture that can be moved through + // + // A picture-in-picture window shows one