diff --git a/frontend/css/style.css b/frontend/css/style.css index 7ee3091..29c0649 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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. */ -body.feed-hud-idle .feed-pane-tools { +.feed-hud-idle .feed-pane-tools { opacity: 0; 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 interactive (pointer-events untouched) so the buttons keep working while invisible; any pointer/scroll activity reveals them again (see App.feed). */ -body.feed-hud-idle .feed-info, -body.feed-hud-idle .feed-timeline, -body.feed-hud-idle .feed-mute-btn, -body.feed-hud-idle .feed-fav-btn, -body.feed-hud-idle .feed-pip-btn, -body.feed-hud-idle .feed-format-btn, -body.feed-hud-idle .mode-toggle-btn { +.feed-hud-idle .feed-info, +.feed-hud-idle .feed-timeline, +.feed-hud-idle .feed-mute-btn, +.feed-hud-idle .feed-fav-btn, +.feed-hud-idle .feed-pip-btn, +.feed-hud-idle .feed-format-btn, +.feed-hud-idle .mode-toggle-btn { opacity: 0; } diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 1234e8a..8cf7a63 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -48,30 +48,63 @@ App.feed = App.feed || {}; let hudIdleTimer = null; 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() { if (hudIdleTimer) clearTimeout(hudIdleTimer); hudIdleTimer = setTimeout(() => { hudIdleTimer = null; 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 // 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); }; const wakeHud = function() { - document.body.classList.remove('feed-hud-idle'); + setHudIdle(false); if (state.feedOpen) scheduleHudHide(); }; - const getScroller = () => document.getElementById('feed-scroll'); - const getSentinel = () => document.getElementById('feed-sentinel'); - const getTopSpacer = () => document.getElementById('feed-top-spacer'); + const inRoot = function(id) { + const root = getRoot(); + 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 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. @@ -243,26 +276,33 @@ App.feed = App.feed || {}; // identically. Feed's own timeline/favorite/title and scroll-snap // slide-to-slide navigation are untouched (see bindTimeline above and // setActive/onScroll below). - const bindSharedControls = function(slide, video, videoData) { + const bindSharedControls = function(pane, video, videoData) { const cleanups = []; const escalator = App.customPlayer.createSkipEscalator(); cleanups.push(() => escalator.destroy()); const doSkip = (direction) => { const amount = App.customPlayer.skip(video, direction, escalator); - flashFeed(slide, `${direction === 'forward' ? '+' : '-'}${amount}s`); + flashFeed(pane, `${direction === 'forward' ? '+' : '-'}${amount}s`); wakeHud(); }; - const pipBtn = slide.querySelector('.feed-pip-btn'); + const pipBtn = pane.querySelector('.feed-pip-btn'); if (pipBtn) { - pipBtn.hidden = !App.customPlayer.supportsPiP(); + pipBtn.hidden = !App.customPlayer.supportsPiP() && !App.feed.docPipSupported(); const onClick = async (event) => { event.stopPropagation(); + if (App.feed.docPipOpen()) { + App.feed.closeDocPip(); + return; + } if (document.pictureInPictureElement) { await document.exitPictureInPicture().catch(() => {}); 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); }; pipBtn.addEventListener('click', onClick); @@ -272,15 +312,15 @@ App.feed = App.feed || {}; // exist, so binding every pane makes them race and the winner arbitrary. // The feed picks one deliberately -- see updateAutoPiPTarget. - const formatBtn = slide.querySelector('.feed-format-btn'); - const formatMenu = slide.querySelector('.feed-format-menu'); + const formatBtn = pane.querySelector('.feed-format-btn'); + const formatMenu = pane.querySelector('.feed-format-menu'); const onFormatPick = (fmt) => { - slide._formatOverride = fmt; + pane._formatOverride = fmt; const t = video.currentTime; if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t); // Tear down the current source (mirrors destroySlidePlayback's // 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 // (fetching segments, attached to the same