window.App = window.App || {}; App.feed = App.feed || {}; (function() { const state = App.state; // Tuning knobs for the virtualized feed. // // The feed is a y-scroll-snap list where every slide is exactly one // viewport tall. We never keep the whole list in the DOM. Instead we keep // a sliding window of slides around the active one: // // [active - HISTORY_COUNT .. active + RENDER_AHEAD] // // Everything outside that range is removed from the document; its video // JSON stays in state.loadedVideos so the slide is rebuilt instantly when // the user scrolls back to (or forward into) it. A top spacer absorbs the // height of the not-yet-rendered slides above the window, so adding or // removing slides never shifts the scroll position: any slide at index i // always sits at scrollTop === i * slideHeight regardless of the window. // // Separately we prefetch PREFETCH_PAGES worth of video JSON ahead of the // active slide so the data buffer is always full before we need to render // a slide from it. // Preloading is counted in steps, so it scales with the split rather than // being shared out between the panels: every panel keeps its own next three // videos ready, which is N * 3 videos loading ahead for a step of N panels. // The step right behind the one being watched buffers for real; the two // behind that hold their metadata, which is what makes their first frame // immediate without four more streams downloading in full. const PRELOAD_STEPS = 3; // steps ahead kept loaded, per panel const BUFFER_STEPS = 1; // of those, how many buffer rather than just resolve const RENDER_AHEAD = 5; // slides ahead kept materialized in the DOM const HISTORY_COUNT = 5; // slides behind kept materialized in the DOM const PREFETCH_PAGES = 2; // pages of JSON to keep buffered ahead of the active slide // Map of loadedVideos index -> rendered .feed-slide element. const slidesByIndex = new Map(); let scrollBound = false; let scrollRaf = null; // While true, scroll events are ignored. A viewport change (e.g. an // orientation switch) makes the scroll-snap container re-snap and fire // scroll events with positions that no longer map to the active slide; // onResize sets this for the brief realign window so those events don't // flip the active video -- rotating the device must never change which // slide is playing. Normal swipes (no resize in flight) are unaffected. let suppressScroll = false; let resizeSettleRaf = null; let resizeSettleTimer = null; // A rotation is not one event. The viewport, the browser's own chrome and // the scroll-snap container settle over many frames and often several // `resize` events, so the realign window is held open until the slide // height has held still for a stretch of frames -- not for a fixed two, // which ran out while the viewport was still moving and let the stale // scroll positions through, skipping the reel forward a few videos. const SETTLE_STABLE_FRAMES = 10; const SETTLE_MAX_MS = 1500; let settleStartedAt = 0; let settleStableFrames = 0; let settleHeight = -1; let settleDone = null; // run once the window closes, if anything asked // HUD auto-hide: the reels HUD fades out after this much inactivity and // reappears on any pointer movement / tap / scroll. Buttons keep their // pointer-events while hidden, so they stay clickable even when invisible. const HUD_IDLE_MS = 1000; 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; 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. const root = getRoot(); if (root) root.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; }); }, HUD_IDLE_MS); }; const wakeHud = function() { setHudIdle(false); if (state.feedOpen) scheduleHudHide(); }; // Moving through the feed is not using the HUD. A swipe to the next reel // used to put the controls up over the video arriving -- the one thing the // swipe was for -- and leave them there for a second. So the scroll puts // them away instead, and only a real interaction (a tap, the mouse moving, // a control being used) brings them back. const sleepHud = function() { if (hudIdleTimer) { clearTimeout(hudIdleTimer); hudIdleTimer = null; } setHudIdle(true); const root = getRoot(); if (root) root.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; }); }; 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(); 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. const stepCount = function() { const total = (state.loadedVideos || []).length; return total === 0 ? 0 : Math.ceil(total / paneCount()); }; const clampIndex = function(index) { const total = stepCount(); if (total === 0) return -1; return Math.min(total - 1, Math.max(0, index)); }; // True when the user wants a finished clip to replay; false when it should // auto-advance to the next video. Defaults to looping (see storage). const shouldLoop = function() { return App.storage.getFeedEndBehavior() !== 'scroll'; }; // Smoothly scrolls to the slide after `fromIndex`; the scroll-snap container // fires onScroll, which promotes the new slide to active. No-ops at the end // of the list so the final clip simply stops on its last frame. const advanceToNext = function(fromIndex) { const next = clampIndex(fromIndex + 1); if (next < 0 || next === fromIndex) return; const scroller = getScroller(); if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' }); }; // ------------------------------------------------------------------ // Sound in a split // // iOS gives a page one audible video at a time. Start a second one with // sound and the system pauses the first -- which, in a split of two or // four panels, reads as "only one panel is playing". Muted video carries // no such limit, so where the rule applies exactly one panel keeps its // sound and the rest are silenced: four pictures moving is what the split // is for, and four audio tracks at once was never the point. // // Nothing in the platform announces this, and by the time the symptom // shows -- a video we started, stopped by something that isn't us -- the // reader has already watched a panel die. So it is read off the device // rather than discovered. const audioIsExclusive = (function() { const ua = navigator.userAgent || ''; if (/iPhone|iPad|iPod/.test(ua)) return true; // iPadOS 13+ claims to be a Mac; the touch points give it away. return /Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1; })(); // Leaves at most one audible panel in the step being watched. `prefer` is // the panel whose sound was just asked for, if any; otherwise the first // one already audible keeps it. Silenced panels get their own button // updated too, so what is shown never disagrees with what is heard. const limitAudibleToOne = function(prefer) { if (!audioIsExclusive) return; const slide = slidesByIndex.get(state.feedActiveIndex); if (!slide) return; let kept = (prefer && prefer._muted === false) ? prefer : null; panesOf(slide).forEach((pane) => { if (pane._muted) return; if (!kept) { kept = pane; return; } if (pane === kept) return; pane._muted = true; if (pane._syncMute) pane._syncMute(); }); }; // Starts whatever in the current step is loaded but stopped -- which is // what iOS leaves behind when a panel's sound is taken away from it. const resumeActivePanes = function() { const slide = slidesByIndex.get(state.feedActiveIndex); if (!slide) return; panesOf(slide).forEach((pane) => { if (!pane.classList.contains('is-loaded')) return; const video = pane.querySelector('.feed-video'); if (!video || !video.paused) return; const playPromise = video.play(); if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); }); }; // Remembers playback position per video id so scrolling away and back // resumes where the user left off. Slides kept in the window are merely // paused (instant resume); slides whose