Files
jacuzzi/frontend/js/feed.js
Simon 7033288bc4 Hold the slides for the whole turn, not until it first pauses
The slides are held at the size a turn is heading for so the picture steps
once, straight into the shape it keeps, instead of being re-letterboxed
through every shape the viewport passes on its way round.

The hold came off once the height had gone six frames unchanged, which
stood in for "the viewport has stopped moving". It does not: a turn
arrives in steps, and the gap between two of them is routinely longer
than six frames -- on a phone and in a loaded tab alike. So the pin came
off mid-turn, the slides took an intermediate shape, and the picture
jumped twice on the way round. That is the stutter the pin exists to
remove, and TURN_HOLD_FRAMES was meant to be "long enough that a viewport
animating round in steps is not mistaken for one that has arrived" -- six
frames is not.

The device does say when a turn is underway, so believe it: while the
announcement stands the pin holds, and the frame count is left to end the
pins nothing announced, where there is no signal to go on and no turn to
ruin. The realign window has to stop concluding mid-turn too, since
closing it releases the pin by the other door; SETTLE_MAX_MS outlasts the
announcement and still guarantees both of them end.

The cost is that an announced turn keeps the old size until the
announcement lapses rather than letting go the moment the viewport looks
still. That buys the one step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 22:26:20 +00:00

2147 lines
97 KiB
JavaScript

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.
//
// What it must not ignore is the reader. A re-snap has nothing behind it;
// a swipe has a finger or a wheel. From the scroll event alone the two are
// identical, so the input is recorded and a scroll that follows one closes
// the window instead of being reverted into it. Without that, a swipe on a
// phone was routinely thrown away: the chrome sliding out of the way *is*
// the scroll, so the window was open at exactly the moment it did most
// harm -- the reel moved under the finger, showing the next video coming
// in, and was then pulled back to where it started.
let suppressScroll = false;
let resizeSettleRaf = null;
let resizeSettleTimer = null;
const INPUT_GRACE_MS = 700;
let lastInputAt = 0;
// 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
let lastMeasuredHeight = -1;
// A turn is not just another resize. The viewport passes through every
// shape between the one it left and the one it lands in, and each one
// re-letterboxes the picture somewhere else: that run of hard jumps is
// what reads as a stutter. So for the length of the turn the slides are
// held at the size they already had, and the moment the viewport stops
// moving they take the size it ended at -- measured, not guessed, so the
// picture moves exactly once, straight into the place it then keeps.
// Only a real turn gets that: the browser's own chrome sliding away moves
// the viewport too, and holding for those would be worse than the jump it
// saves.
//
// `orientationchange` fires before the new metrics are readable, which is
// the earliest anything can know a turn is coming -- early enough to pin
// before the first new shape is painted. It is also the one signal that
// separates a turn from a window being dragged wider on a desktop.
const TURN_SIGNAL_MS = 1200;
// The hold comes off once the viewport has stopped moving -- sooner than
// the realign window closes, which goes on holding the reel in place for
// a while after that in case a last stale scroll arrives. Long enough
// that a viewport animating round in steps is not mistaken for one that
// has arrived, short enough not to be a wait.
const TURN_HOLD_FRAMES = 6;
let turnSignalAt = 0;
let turnPin = null; // the shape the slides are held at, mid-turn
let turnResolved = false;
let settleFromShape = null; // the shape the viewport had before this turn
let lastShape = null;
// 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() {
// Mid-turn the slides are the size the turn is heading for, not the
// size the viewport happens to be passing through, and every scroll
// position in the feed is a multiple of a slide.
if (turnPin) return turnPin.h;
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 <video> is torn down to free
// resources still have their position restored on reload via applyResume.
const KEEP_BEHIND = 2; // slides behind the active one kept loaded & paused
const resumeTimes = new Map();
const slideVideoId = (slide) => (slide && slide._videoData ? slide._videoData.id : null);
const rememberTime = function(slide, video) {
if (!slide || !video || slide.classList.contains('is-live')) return;
const id = slideVideoId(slide);
if (id == null) return;
const t = video.currentTime;
if (isFinite(t) && t > 0.5) resumeTimes.set(id, t);
};
const applyResume = function(video, videoId, isLive) {
if (!video || isLive || videoId == null) return;
const t = resumeTimes.get(videoId);
if (t == null || t <= 0) return;
const seek = () => {
let target = t;
if (isFinite(video.duration) && video.duration > 0) {
target = Math.min(t, video.duration - 0.25);
}
try { video.currentTime = Math.max(0, target); } catch (err) { /* ignore */ }
};
if (video.readyState >= 1) seek();
else video.addEventListener('loadedmetadata', seek, { once: true });
};
// Pauses a slide but keeps its <video> loaded so returning to it resumes
// instantly from the exact frame it was paused on.
const pauseSlide = function(slide) {
const video = slide.querySelector('.feed-video');
slide.classList.remove('is-active');
if (video && !video.paused) video.pause();
rememberTime(slide, video);
};
const destroySlidePlayback = function(slide) {
const video = slide.querySelector('.feed-video');
slide.classList.remove('is-active');
rememberTime(slide, video);
const fill = slide.querySelector('.feed-timeline-fill');
if (fill) fill.style.width = '0%';
const handle = slide.querySelector('.feed-timeline-handle');
if (handle) handle.style.left = '0%';
if (!video) return;
if (video._hlsPlayer) {
video._hlsPlayer.destroy();
video._hlsPlayer = null;
}
// Clearing the src below makes the element fire a spurious `error` event;
// flag the teardown so the failure handler ignores it (see markSlideFailed).
video._tearingDown = true;
video.pause();
video.removeAttribute('src');
video.load();
slide.classList.remove('is-loaded');
};
// Single-line slide title that scrolls when it overflows. Only the active
// slide's title is measured, so like the player it always scrolls.
const measureFeedTitle = function(slide) {
if (!slide) return;
App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
};
const setTimelinePosition = function(slide, ratio) {
const fill = slide.querySelector('.feed-timeline-fill');
const handle = slide.querySelector('.feed-timeline-handle');
const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`;
if (fill) fill.style.width = pct;
if (handle) handle.style.left = pct;
};
const seekFromPointer = function(slide, video, timeline, clientX) {
if (!isFinite(video.duration) || video.duration <= 0) return;
const rect = timeline.getBoundingClientRect();
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
const clamped = Math.min(1, Math.max(0, ratio));
video.currentTime = clamped * video.duration;
setTimelinePosition(slide, clamped);
};
const bindTimeline = function(slide, video) {
const timeline = slide.querySelector('.feed-timeline');
if (!timeline) return;
let scrubbing = false;
video.addEventListener('timeupdate', () => {
if (scrubbing || !isFinite(video.duration) || video.duration <= 0) return;
setTimelinePosition(slide, video.currentTime / video.duration);
});
timeline.addEventListener('pointerdown', (event) => {
scrubbing = true;
// Dragging the scrubber is the one drag that *is* the HUD.
wakeHud();
timeline.classList.add('is-scrubbing');
timeline.setPointerCapture(event.pointerId);
seekFromPointer(slide, video, timeline, event.clientX);
event.preventDefault();
event.stopPropagation();
});
timeline.addEventListener('pointermove', (event) => {
if (!scrubbing) return;
wakeHud();
seekFromPointer(slide, video, timeline, event.clientX);
event.preventDefault();
event.stopPropagation();
});
const stopScrubbing = (event) => {
if (!scrubbing) return;
scrubbing = false;
timeline.classList.remove('is-scrubbing');
if (timeline.hasPointerCapture(event.pointerId)) {
timeline.releasePointerCapture(event.pointerId);
}
event.stopPropagation();
};
timeline.addEventListener('pointerup', stopScrubbing);
timeline.addEventListener('pointercancel', stopScrubbing);
};
const flashFeed = function(slide, text) {
const flashEl = slide.querySelector('.feed-flash');
if (!flashEl) return;
flashEl.textContent = text;
flashEl.classList.remove('is-visible');
void flashEl.offsetWidth;
flashEl.classList.add('is-visible');
};
// Wires the controls shared with the standalone fullscreen player (skip
// escalation + double-tap zones, format switching, PiP) onto a reels
// slide, reusing the same App.customPlayer logic so both surfaces behave
// 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(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(pane, `${direction === 'forward' ? '+' : '-'}${amount}s`);
wakeHud();
};
// On iOS these are the only source of enter/leave events; everywhere
// else this is a no-op and the browser fires them itself.
cleanups.push(App.customPlayer.bindPiPEvents(video));
const pipBtn = pane.querySelector('.feed-pip-btn');
if (pipBtn) {
pipBtn.hidden = !App.customPlayer.supportsPiP() && !App.feed.docPipSupported();
const onClick = async (event) => {
event.stopPropagation();
if (App.feed.docPipOpen()) {
App.feed.closeDocPip();
return;
}
if (App.customPlayer.pipElement()) {
await App.customPlayer.exitPiP();
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);
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
}
// Auto-PiP is not bound per pane: only one picture-in-picture window can
// exist, so binding every pane makes them race and the winner arbitrary.
// The feed picks one deliberately -- see updateAutoPiPTarget.
const formatBtn = pane.querySelector('.feed-format-btn');
const formatMenu = pane.querySelector('.feed-format-menu');
const onFormatPick = (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 pane, so
// the old Hls.js instance must be destroyed or it keeps running
// (fetching segments, attached to the same <video>) forever.
if (video._hlsPlayer) {
video._hlsPlayer.destroy();
video._hlsPlayer = null;
}
video._tearingDown = true;
video.pause();
video.removeAttribute('src');
video.load();
pane.classList.remove('is-loaded');
loadSlideSource(pane, videoData, true);
};
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
{ getCurrentUrl: () => pane._activeUrl || '', onOpen: wakeHud });
let destroyFormatMenu = bindFormats();
cleanups.push(() => destroyFormatMenu());
// 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
// empty. Playback already runs from that page URL through the proxy, so
// resolve in the background and rebuild the menu once the real qualities
// land -- same as the standalone player does.
if (App.videos && typeof App.videos.ensureFormats === 'function') {
App.videos.ensureFormats(videoData).then((meta) => {
// Bail if the pane was torn down (or rebound) in the meantime.
if (!meta || pane._sharedControlCleanups !== cleanups) return;
destroyFormatMenu();
destroyFormatMenu = bindFormats();
});
}
cleanups.push(App.customPlayer.attachGestures(pane, {
onSingleTap: wakeHud,
onDoubleTapLeft: () => doSkip('back'),
onDoubleTapRight: () => doSkip('forward'),
ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline'
}));
pane._sharedControlCleanups = cleanups;
};
// The tallest rendition worth decoding for this pane: its own height in
// device pixels, rounded up to the next common rendition so a pane a little
// over 360px doesn't get 360p. Returns 0 (no cap) for a single full-screen
// pane, which is the old behaviour.
const RENDITION_STEPS = [240, 360, 480, 720, 1080, 1440, 2160];
const paneHeightCap = function(pane) {
if (paneCount() <= 1) return 0;
const box = pane.getBoundingClientRect();
if (!box.height) return 0;
const needed = box.height * (window.devicePixelRatio || 1);
return RENDITION_STEPS.find((step) => step >= needed) || 0;
};
// `opts.silent` plays without sound whatever the panel's own switch says --
// for a panel that is only half on screen (see peek), whose audio would
// otherwise talk over the one still being watched. `opts.buffer` asks for a
// real buffer rather than metadata alone, for a step close enough that it
// is about to be swiped to.
const loadSlideSource = function(slide, videoData, autoplay, opts) {
const video = slide.querySelector('.feed-video');
if (!video) return;
const silent = !!(opts && opts.silent);
if (slide.classList.contains('is-loaded')) {
if (autoplay) {
// Picks up a stream that was parked after preloading.
if (video._hlsPlayer) video._hlsPlayer.startLoad();
video.muted = silent || slide._muted !== false;
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
}
return;
}
// A slide whose formats haven't been resolved yet carries only a page
// URL, and handing that to /api/stream makes the backend re-run yt-dlp
// per request (slow, and a hard failure on some sites). Resolve once,
// then load for real -- `_awaitingFormats` keeps a failed resolve from
// looping, so we still fall back to the page URL as a last resort.
const meta = videoData && (videoData.meta || videoData);
const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length);
if (!hasFormats && !slide._awaitingFormats && App.videos && typeof App.videos.ensureFormats === 'function') {
slide._awaitingFormats = true;
App.videos.ensureFormats(videoData).then(() => {
if (slide._videoData === videoData) loadSlideSource(slide, videoData, autoplay);
});
return;
}
slide.classList.add('is-loaded');
const resolved = slide._formatOverride
? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
: App.videos.resolveStreamSource(videoData, {
maxHeight: paneHeightCap(slide),
// Several panels at once is a decoder problem, not a picture
// problem: prefer a progressive file over HLS, H.264 over AV1,
// 30fps over 60.
cheapest: paneCount() > 1
});
if (!resolved || !resolved.url) {
// No playable source -- treat exactly like a load failure so the
// clip is dropped from the queue and the next one takes its place.
markSlideFailed(slide);
return;
}
// What's actually on screen, so the quality menu can tick it.
slide._activeUrl = resolved.url;
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
const isHls = App.videos.classifySource(resolved).isHls;
video.muted = silent || slide._muted !== false;
// The step being watched and the one right behind it buffer properly,
// so a swipe lands on video that is already there. Steps further ahead
// take their metadata only: enough to start without delay, without four
// more streams downloading in full behind them.
video.preload = (autoplay || (opts && opts.buffer)) ? 'auto' : 'metadata';
video._tearingDown = false;
applyResume(video, videoData && videoData.id, resolved.isLive);
const startPlay = () => {
if (!autoplay) return;
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
};
const attachHls = (HlsLib) => {
const split = paneCount() > 1;
const hls = new HlsLib(split ? {
// Never fetch a rendition larger than the pane it draws into.
capLevelToPlayerSize: true,
// Several streams at once, each holding a minute of video, is
// memory and demuxing work for footage nobody has reached yet.
maxBufferLength: 10,
maxMaxBufferLength: 20,
backBufferLength: 10
} : {});
video._hlsPlayer = hls;
hls.loadSource(streamUrl);
hls.attachMedia(video);
if (!autoplay && split && !(opts && opts.buffer)) {
// Buffered enough to start instantly, then stopped: four
// panels' worth of hls.js all fetching and demuxing ahead is
// work for a step the reader has not swiped to yet. The step
// right behind the active one is exempt -- it is the one about
// to be swiped to, and maxBufferLength stops it on its own.
// loadSlideSource runs again with autoplay when they do.
hls.on(HlsLib.Events.FRAG_BUFFERED, function once() {
hls.off(HlsLib.Events.FRAG_BUFFERED, once);
if (video._hlsPlayer === hls) hls.stopLoad();
});
}
hls.on(HlsLib.Events.ERROR, (event, data) => {
if (data && data.fatal && video._hlsPlayer === hls) {
hls.destroy();
video._hlsPlayer = null;
// A fatal HLS error means the stream won't play: drop it.
markSlideFailed(slide);
}
});
startPlay();
};
const startNative = () => {
video.src = streamUrl;
startPlay();
};
if (!isHls) {
startNative();
return;
}
if (window.Hls && window.Hls.isSupported()) {
attachHls(window.Hls);
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
startNative();
} else {
// Lazy-load hls.js on demand for the first HLS slide.
App.ensureHls()
.then((HlsLib) => {
if (HlsLib && HlsLib.isSupported()) attachHls(HlsLib);
else startNative();
})
.catch(startNative);
}
};
// Builds (or returns) the .feed-slide element for loadedVideos[index] and
// inserts it into the DOM in index order, between the top spacer and the
// sentinel.
// ------------------------------------------------------------------
// Panes
//
// A step of the feed is one screenful, and it holds one or more panes. The
// arrangement is a tree: every pane can be split to the right or below,
// which replaces it with a two-child split, so any nesting is reachable.
// Leaves, read in order, are the panes of a step.
//
// Scrolling drives all of them at once: with N panes, step i shows videos
// i*N to i*N+N-1, and one swipe advances the whole set.
// ------------------------------------------------------------------
let paneTree = { type: 'pane' };
const paneLeaves = function(node, out) {
out = out || [];
if (node.type === 'pane') out.push(node);
else node.children.forEach((child) => paneLeaves(child, out));
return out;
};
// Panes per step, which is the stride between one swipe and the next.
const paneCount = function() {
return paneLeaves(paneTree).length;
};
App.feed.paneCount = paneCount;
// Replaces `target` with a split holding it and a new pane.
const splitPane = function(target, direction) {
const replace = function(node) {
if (node === target) {
return { type: 'split', dir: direction, children: [target, { type: 'pane' }] };
}
if (node.type === 'split') {
node.children = node.children.map(replace);
}
return node;
};
paneTree = replace(paneTree);
rebuildLayout();
};
// Drops a pane, collapsing the split that held it so no split is ever left
// with a single child.
const closePane = function(target) {
if (paneCount() <= 1) return;
const prune = function(node) {
if (node.type !== 'split') return node;
const kept = node.children.filter((child) => child !== target).map(prune);
return kept.length === 1 ? kept[0] : Object.assign(node, { children: kept });
};
paneTree = prune(paneTree);
rebuildLayout();
};
// The pane count is the stride, so changing it renumbers every step. Rebuild
// around whatever video is playing so the reader keeps their place.
const rebuildLayout = function() {
const keepId = state.feedActiveVideoId;
reserveSlides();
const videos = state.loadedVideos || [];
let videoIndex = 0;
if (keepId != null) {
const found = videos.findIndex((v) => String(v.id) === String(keepId));
if (found >= 0) videoIndex = found;
}
state.feedActiveIndex = -1;
const step = Math.floor(videoIndex / paneCount());
setActive(step);
releaseReserve();
const scroller = getScroller();
if (scroller) scroller.scrollTop = step * slideHeight();
};
// ------------------------------------------------------------------
// Reusing panes across a layout change
//
// Splitting or closing a panel changes the stride, so every step is
// renumbered and the slides have to be built again -- but the videos
// haven't changed. A pane already holds one: its <video> with the bytes it
// has buffered, where it had got to, which quality it was told to play.
// Building a fresh one throws all of that away and fetches it again, so
// the panes are set aside by video id first and whoever needs that video
// next takes the pane that already has it.
//
// Everything happens in one go, without yielding, so a pane never stops
// being in the document long enough to be paused for it.
// ------------------------------------------------------------------
const paneReserve = new Map();
const reserveSlides = function() {
slidesByIndex.forEach((slide) => {
panesOf(slide).forEach((pane) => {
const id = slideVideoId(pane);
const key = id == null ? null : String(id);
if (key !== null && !paneReserve.has(key)) {
paneReserve.set(key, pane);
if (pane.parentNode) pane.parentNode.removeChild(pane);
return;
}
teardownPane(pane); // a second pane on the same video
});
slide._panes = [];
slide.remove();
});
slidesByIndex.clear();
forgetPeeks();
};
// Whatever the new layout had no place for -- it is holding a stream open.
const releaseReserve = function() {
paneReserve.forEach(teardownPane);
paneReserve.clear();
};
// Everything one video needs on screen. Identical to what a slide used to
// hold: the per-video helpers below take any element shaped like this, so a
// pane and the old single-video slide are interchangeable to them.
const buildPane = function(v, index) {
const held = v && v.id != null ? paneReserve.get(String(v.id)) : null;
if (held) {
paneReserve.delete(String(v.id));
// The stride changed, so this video sits at a different place in
// the queue than it did; nothing else about the pane has moved.
held.dataset.index = String(index);
held._index = index;
held._videoData = v;
return held;
}
const pane = document.createElement('div');
pane.className = v.isLive ? 'feed-pane is-live' : 'feed-pane';
pane.dataset.videoId = v.id;
pane.dataset.index = String(index);
pane._videoData = v;
pane._index = index;
const uploaderText = v.uploader || '';
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
const favKey = App.favorites ? App.favorites.getKey(v) : null;
pane.innerHTML = `
<img class="feed-poster" alt="" loading="lazy" decoding="async">
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
${liveBadge}
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
<button class="cp-pip-btn feed-pip-btn" type="button" aria-label="Picture in picture" hidden>
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
</button>
<button class="cp-format-btn feed-format-btn" type="button" aria-label="Quality" hidden>HD</button>
<div class="cp-format-menu feed-format-menu" role="menu" hidden></div>
<div class="feed-pane-tools">
<button class="feed-pane-btn feed-pane-mute" type="button" aria-label="Mute this panel"></button>
<button class="feed-pane-btn feed-pane-split-right" type="button" title="Add a panel to the right" aria-label="Add a panel to the right">◫</button>
<button class="feed-pane-btn feed-pane-split-down" type="button" title="Add a panel below" aria-label="Add a panel below">⊟</button>
<button class="feed-pane-btn feed-pane-close" type="button" title="Close this panel" aria-label="Close this panel" hidden>✕</button>
</div>
<div class="cp-flash feed-flash" aria-hidden="true"></div>
<div class="feed-info">
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
</div>
<div class="feed-timeline" role="slider" aria-label="Seek">
<div class="feed-timeline-track">
<div class="feed-timeline-fill"></div>
<div class="feed-timeline-handle"></div>
</div>
</div>
`;
const poster = pane.querySelector('.feed-poster');
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
const slideVideo = pane.querySelector('.feed-video');
bindTimeline(pane, slideVideo);
bindSharedControls(pane, slideVideo, v);
// A media error (bad/expired source, network failure, unsupported codec)
// means this clip can't play -- drop it from the queue. Errors fired by
// our own teardown (src cleared) carry the _tearingDown flag and are
// ignored inside markSlideFailed.
slideVideo.addEventListener('error', () => markSlideFailed(pane));
// On video end, either loop (handled by the `loop` flag, so `ended`
// never fires) or auto-scroll to the next clip. We only advance for the
// active slide so a preloaded neighbour ending early can't hijack focus.
slideVideo.loop = shouldLoop();
slideVideo.addEventListener('ended', () => {
if (shouldLoop()) return;
// Only the active step advances, and only from its first pane, so a
// short clip in one panel can't drag the others along with it.
const slide = pane.closest('.feed-slide');
if (!slide || !slide.classList.contains('is-active')) return;
if (slide._panes && slide._panes[0] !== pane) return;
advanceToNext(slide._step);
});
const favBtn = pane.querySelector('.feed-fav-btn');
if (favBtn && App.favorites) {
App.favorites.setButtonState(favBtn, App.favorites.has(v));
favBtn.addEventListener('click', (event) => {
event.stopPropagation();
App.favorites.toggle(v);
});
}
// Each panel owns its own sound, so two can play at once if that is
// what the reader wants. A new panel inherits whatever the feed-wide
// control is set to, so a step built later doesn't disagree with the
// ones already on screen -- which does mean splitting while unmuted
// gives you two audio tracks.
const muteBtn = pane.querySelector('.feed-pane-mute');
const syncMute = () => {
slideVideo.muted = pane._muted;
muteBtn.textContent = pane._muted ? '🔇' : '🔊';
muteBtn.setAttribute('aria-label', pane._muted ? 'Unmute this panel' : 'Mute this panel');
muteBtn.classList.toggle('is-muted', !!pane._muted);
};
pane._muted = state.feedMuted !== false;
pane._syncMute = syncMute;
syncMute();
muteBtn.addEventListener('click', (event) => {
event.stopPropagation();
pane._muted = !pane._muted;
syncMute();
// Asking for this panel's sound gives up another's, where only one
// panel may have any -- and the one that lost it may have been
// stopped by the system on the way, so it is started again.
limitAudibleToOne(pane);
resumeActivePanes();
refreshFeedMuteState();
// The panel you can hear is the one that should follow you out.
updateAutoPiPTarget();
});
return pane;
};
// The feed-wide button reads as muted only while every panel is, so it
// can't claim silence over a panel someone unmuted by hand.
const refreshFeedMuteState = function() {
let anyAudible = false;
slidesByIndex.forEach((slide) => {
panesOf(slide).forEach((pane) => { if (!pane._muted) anyAudible = true; });
});
state.feedMuted = !anyAudible;
App.feed.updateMuteButton();
};
// Renders the layout tree into elements, handing each leaf the next video.
const renderPaneTree = function(node, videos, cursor) {
if (node.type === 'pane') {
const v = videos[cursor.next++];
if (!v) return null;
const pane = buildPane(v, cursor.next - 1);
pane._node = node;
bindPaneTools(pane, node);
return pane;
}
const split = document.createElement('div');
split.className = 'feed-split ' + (node.dir === 'row' ? 'is-row' : 'is-col');
node.children.forEach((child) => {
const el = renderPaneTree(child, videos, cursor);
if (el) split.appendChild(el);
});
return split.childElementCount ? split : null;
};
// Rebindable, because a pane outlives the tree it was built for: a reused
// pane comes back under a different node, and its old buttons would still
// be splitting and closing the one it left.
const bindPaneTools = function(pane, node) {
if (Array.isArray(pane._toolCleanups)) {
pane._toolCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
}
const cleanups = [];
const on = function(selector, handler) {
const el = pane.querySelector(selector);
if (!el) return el;
el.addEventListener('click', handler);
cleanups.push(() => el.removeEventListener('click', handler));
return el;
};
const closeBtn = pane.querySelector('.feed-pane-close');
closeBtn.hidden = paneCount() <= 1;
on('.feed-pane-split-right', (event) => {
event.stopPropagation();
splitPane(node, 'row');
});
on('.feed-pane-split-down', (event) => {
event.stopPropagation();
splitPane(node, 'col');
});
on('.feed-pane-close', (event) => {
event.stopPropagation();
closePane(node);
});
pane._toolCleanups = cleanups;
};
// One screenful: the pane tree, filled with this step's run of videos.
const createSlide = function(step) {
if (slidesByIndex.has(step)) return slidesByIndex.get(step);
const videos = state.loadedVideos || [];
const per = paneCount();
const first = step * per;
if (!videos[first]) return null;
const scroller = getScroller();
if (!scroller) return null;
const slide = document.createElement('div');
slide.className = 'feed-slide';
slide.dataset.step = String(step);
slide._step = step;
const cursor = { next: first };
const tree = renderPaneTree(paneTree, videos, cursor);
if (!tree) return null;
slide.appendChild(tree);
slide._panes = Array.from(slide.querySelectorAll('.feed-pane'));
// Insert before the rendered slide with the next-highest step so DOM
// order always matches step order; fall back to the sentinel.
let ref = getSentinel();
let refStep = Infinity;
slidesByIndex.forEach((el, i) => {
if (i > step && i < refStep) {
refStep = i;
ref = el;
}
});
scroller.insertBefore(slide, ref);
slidesByIndex.set(step, slide);
return slide;
};
// Tears down everything a slide holds -- playback (video/hls) plus the
// shared skip/format/PiP/gesture bindings from bindSharedControls -- but
// does not remove it from the DOM or from slidesByIndex (callers differ
// on that: removeSlide always does, reset() removes the whole tree at
// once).
// A slide is now a container: everything below that used to act on one
// video acts on one pane, and the slide fans out over its panes.
const panesOf = function(slide) {
return (slide && slide._panes) || [];
};
const teardownPane = function(pane) {
destroySlidePlayback(pane);
if (Array.isArray(pane._sharedControlCleanups)) {
pane._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
pane._sharedControlCleanups = null;
}
};
const teardownSlide = function(slide) {
panesOf(slide).forEach(teardownPane);
};
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);
};
// Drops a video that failed to load/resolve from the queue and pulls the
// next clip into its place.
//
// Removing a video renumbers everything after it, and with panes a step is
// a run of videos rather than one -- so the steps from the hole onwards
// have to be rebuilt. The steps *before* it keep their videos, which is
// what lets a failed preload neighbour leave the active panes playing
// untouched; only a failure at or before what's on screen forces the
// active step to be rebuilt with it.
const removeVideoFromQueue = function(videoId) {
const videos = state.loadedVideos || [];
const r = videos.findIndex((v) => String(v.id) === String(videoId));
if (r < 0) return;
// Which video to stay with afterwards: the one that slides into the
// failed clip's place, or the last one if it was at the end.
const keep = videos[r + 1] || videos[r - 1] || null;
videos.splice(r, 1);
// Remove the failed clip's grid card element from the DOM too (the grid
// shares the queue) and re-pack the remaining cards.
if (App.virtualGrid && typeof App.virtualGrid.removeVideo === 'function') {
App.virtualGrid.removeVideo(videoId);
}
if (videos.length === 0) {
App.feed.close();
return;
}
const per = paneCount();
const activeLast = (state.feedActiveIndex + 1) * per - 1;
if (state.feedActiveIndex >= 0 && r > activeLast) {
// The hole is past everything on screen: drop the steps from it
// onwards and let the window rebuild them, leaving the active panes
// mid-playback exactly as they are.
const fromStep = Math.floor(r / per);
const stale = [];
slidesByIndex.forEach((slide, i) => { if (i >= fromStep) stale.push(i); });
stale.forEach(removeSlide);
syncWindow(state.feedActiveIndex);
return;
}
state.feedActiveVideoId = keep ? keep.id : null;
rebuildLayout();
};
// Flags a slide whose video failed and schedules its removal from the queue.
// Deferred to a macrotask so we never mutate slidesByIndex while setActive /
// syncWindow is mid-iteration over it. Teardown-induced errors (src cleared)
// are ignored via the video's _tearingDown flag, and we only act while the
// feed is open so late errors after close are harmless.
const markSlideFailed = function(pane) {
if (!pane || pane._failed || !state.feedOpen) return;
const video = pane.querySelector('.feed-video');
if (video && video._tearingDown) return;
const id = slideVideoId(pane);
if (id == null) return;
pane._failed = true;
setTimeout(() => removeVideoFromQueue(id), 0);
};
// Brings the rendered window in line with the active index: drops slides
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
// any missing ones inside it, and sizes the top spacer to stand in for the
// slides above the window.
// Which steps are materialised. Behind, the window narrows as panes are
// added -- history is the cheap thing to give up. Ahead it does not: what
// is preloaded has to be in the document to be preloaded at all, so the
// window always reaches at least PRELOAD_STEPS forward however many panels
// there are. A four-way split therefore holds four times as many elements
// as a single panel, which is the price of four panels never waiting.
//
// The top spacer stands in for the steps below `start`, so anything that
// sizes the spacer has to agree with this exactly -- disagreeing by a step
// shifts every rendered slide and lands the reader on the wrong videos.
const windowBounds = function(activeIndex) {
const per = paneCount();
// The floor of one step is not a rounding guard: it is what guarantees
// the step after this one always exists, however many panes there are.
// Every panel's next video lives in that step, so lowering it below one
// would leave a panel with nothing buffered to swipe to.
return {
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
end: Math.min(stepCount() - 1,
activeIndex + Math.max(PRELOAD_STEPS, Math.round(RENDER_AHEAD / per)))
};
};
const syncWindow = function(activeIndex) {
if (stepCount() === 0) return;
const { start, end } = windowBounds(activeIndex);
const per = paneCount();
const videos = state.loadedVideos || [];
slidesByIndex.forEach((slide, i) => {
if (i < start || i > end) {
removeSlide(i);
return;
}
// A step built before all of its videos had arrived is short a pane
// or more. Once the rest land it has to be rebuilt, or the videos
// that would have filled it are skipped for good: the next step
// starts past them.
const have = (slide._panes || []).length;
if (have < per && videos[i * per + have]) removeSlide(i);
});
for (let i = start; i <= end; i++) {
if (!slidesByIndex.has(i)) createSlide(i);
}
const spacer = getTopSpacer();
if (spacer) spacer.style.height = `${start * slideHeight()}px`;
};
// The videos the next few swipes will want, resolved now and all at once.
// Resolving is a round trip per video (yt-dlp, on the backend), and leaving
// it until load time put that round trip between a panel and its first
// frame -- with four panels, four of them, arriving one after another. They
// are deduped per video, so asking early costs nothing but the request, and
// asking for all of them at once is the point: they run in parallel.
const warmFormats = function(activeIndex) {
if (!App.videos || typeof App.videos.ensureFormats !== 'function') return;
const videos = state.loadedVideos || [];
const per = paneCount();
const from = Math.max(0, activeIndex * per);
const to = Math.min(videos.length, from + per * (PRELOAD_STEPS + 1));
for (let i = from; i < to; i++) {
if (videos[i]) App.videos.ensureFormats(videos[i]);
}
};
// Keeps the JSON buffer ahead of what the feed is about to eat. A step of N
// panels consumes N videos per swipe, so the same page of listing goes N
// times as fast -- the buffer it needs, and the number of pages worth having
// on the wire at once, both scale with the split.
const prefetchIfNeeded = function(activeIndex) {
const per = paneCount();
const perPage = state.perPage || 12;
const total = (state.loadedVideos || []).length;
const bufferAhead = total - 1 - ((activeIndex + 1) * per - 1);
// Everything the rendered window will reach into, plus room to grow.
const needed = Math.max(PREFETCH_PAGES * perPage, per * (PRELOAD_STEPS + 2));
if (App.videos.setPrefetchDepth) {
App.videos.setPrefetchDepth(Math.ceil(needed / perPage));
}
if (bufferAhead < needed && state.hasNextPage && !state.isLoading) {
// The feed is its own reader: the grid's scroll position says
// nothing about whether it needs the next page.
App.videos.loadVideos({ force: true });
}
};
// Promotes the slide at `index` to active: syncs the window, updates the
// active styling, plays it, preloads the next PRELOAD_STEPS, and tears down
// playback for everything else in the window.
const setActive = function(index) {
const clamped = clampIndex(index);
if (clamped < 0) return;
state.feedActiveIndex = clamped;
const activeVideo = (state.loadedVideos || [])[clamped * paneCount()];
state.feedActiveVideoId = activeVideo ? activeVideo.id : null;
syncWindow(clamped);
slidesByIndex.forEach((slide, i) => {
slide.classList.toggle('is-active', i === clamped);
});
const activeSlide = slidesByIndex.get(clamped);
if (activeSlide) {
// Settled before anything starts: a second panel starting with
// sound is exactly what stops the first one (see audioIsExclusive).
limitAudibleToOne();
panesOf(activeSlide).forEach((pane) => {
loadSlideSource(pane, pane._videoData, true);
requestAnimationFrame(() => measureFeedTitle(pane));
});
}
slidesByIndex.forEach((slide, i) => {
if (i === clamped) return;
const ahead = i - clamped;
panesOf(slide).forEach((pane) => {
if (ahead > 0 && ahead <= PRELOAD_STEPS) {
// Every panel of the step, started together: what used to
// make them start one after another is that only the first
// step ahead was loaded, so each panel did its resolving
// and buffering at the moment it was swiped to.
loadSlideSource(pane, pane._videoData, false, { buffer: ahead <= BUFFER_STEPS });
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
// 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') && pane !== pipPane) {
destroySlidePlayback(pane);
}
});
});
updateAutoPiPTarget();
warmFormats(clamped);
prefetchIfNeeded(clamped);
};
// ------------------------------------------------------------------
// Peeking
//
// A step used to start playing when the swipe was already half done --
// `Math.round` of the scroll position -- and then still had to load. So
// the video slid into view as a still picture and began some time after
// it arrived, which is most of the waiting in a reel.
//
// Now the step coming into view starts as soon as any of it is showing,
// silently, while the one being left plays on. A swipe that is carried
// through therefore lands on a video that is already running; one that is
// abandoned leaves nothing behind -- the step that was peeked at is
// stopped and wound back, so the next swipe to it starts it from the top
// rather than from the second and a half nobody watched.
// ------------------------------------------------------------------
const PEEK_MIN_PX = 8; // less than this is a twitch, not a swipe
const PEEK_SETTLE_MS = 140; // quiet for this long means the scroll is over
const peeked = new Set(); // step elements started by a peek
let peekSettleTimer = null;
const startPeek = function(index) {
const slide = slidesByIndex.get(index);
if (!slide || peeked.has(slide)) return;
peeked.add(slide);
panesOf(slide).forEach((pane) => {
const video = pane.querySelector('.feed-video');
const id = slideVideoId(pane);
// Where this panel stood before anyone looked at it, so an
// abandoned swipe can put it back exactly there: the start for a
// step never watched, and where it was left for one scrolled past.
pane._peekFrom = (video && video.currentTime) ||
(id != null && resumeTimes.get(id)) || 0;
loadSlideSource(pane, pane._videoData, true, { silent: true });
});
};
// Stops a panel and puts it back where the peek found it -- the opposite
// of pauseSlide, which keeps where it got to on purpose.
const rewindPane = function(pane) {
const video = pane.querySelector('.feed-video');
const back = pane._peekFrom || 0;
pane.classList.remove('is-active');
if (video) {
if (!video.paused) video.pause();
try { video.currentTime = back; } catch (err) { /* not seekable yet */ }
}
const id = slideVideoId(pane);
if (id != null) {
if (back > 0) resumeTimes.set(id, back);
else resumeTimes.delete(id);
}
pane._peekFrom = 0;
};
// The scroll has stopped somewhere: whatever was peeked at and not arrived
// at goes back to where it started.
//
// "Stopped" has to mean stopped *on* a step. Scroll events also stop while
// a finger holds the list still between two of them -- the swipe is then
// neither finished nor abandoned, and winding the incoming step back under
// a held finger is precisely the stall this is here to remove.
const settlePeek = function() {
peekSettleTimer = null;
if (!peeked.size) return;
const scroller = getScroller();
const height = slideHeight();
if (scroller && height) {
const offset = scroller.scrollTop % height;
if (offset > PEEK_MIN_PX && offset < height - PEEK_MIN_PX) return;
}
const active = slidesByIndex.get(state.feedActiveIndex);
peeked.forEach((slide) => {
if (slide === active) return;
panesOf(slide).forEach(rewindPane);
});
peeked.clear();
};
// Nothing to put back once the steps themselves are gone.
const forgetPeeks = function() {
if (peekSettleTimer) {
clearTimeout(peekSettleTimer);
peekSettleTimer = null;
}
peeked.clear();
};
App.feed._settlePeek = settlePeek; // tests drive the settle directly
const peekIncoming = function(scroller) {
const height = slideHeight();
if (!height) return;
const first = Math.floor(scroller.scrollTop / height);
const offset = scroller.scrollTop - first * height;
if (offset < PEEK_MIN_PX) return; // nothing else is showing yet
// Two steps are on screen; the one that isn't being watched is the one
// arriving, whichever direction the swipe is going.
[first, first + 1].forEach((index) => {
if (index === state.feedActiveIndex) return;
if (index < 0 || index >= stepCount()) return;
startPeek(index);
});
};
const onScroll = function() {
// A turn's re-anchor arrives as a scroll event carrying a position
// measured against the height from before it. Putting it back here,
// in the event, is what keeps that position from being painted --
// waiting for the next frame means showing it first.
if (suppressScroll) {
// Unless it is the reader scrolling. A swipe closes the window
// instead of being reverted into it -- and closes it without a
// realign, or the close would undo the swipe that caused it.
if (Date.now() - lastInputAt >= INPUT_GRACE_MS) {
realignToActive();
return;
}
closeSettle(false);
}
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
const scroller = getScroller();
if (!scroller) return;
// Ignore scroll events fired by a resize/orientation re-snap; the
// active video is realigned by onResize instead (see suppressScroll).
if (suppressScroll) return;
sleepHud();
peekIncoming(scroller);
if (peekSettleTimer) clearTimeout(peekSettleTimer);
peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS);
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
if (index < 0) return;
if (index !== state.feedActiveIndex) {
setActive(index);
}
});
};
const viewportShape = function() {
const scroller = getScroller();
const view = feedDocument().defaultView || window;
return {
w: (scroller && scroller.clientWidth) || view.innerWidth || 1,
h: (scroller && scroller.clientHeight) || view.innerHeight || 1
};
};
// What a wrist did, as against what the browser's chrome did: the device
// said so, or the viewport swapped its long side for its short one. A
// viewport that only changes height -- chrome sliding away, a keyboard
// coming up -- is not a turn and is not worth a fade.
const isTurn = function(before, now) {
if (Date.now() - turnSignalAt < TURN_SIGNAL_MS) return true;
if (!before) return false;
// Swapping the long side moves both of them. Reading the flip on its
// own calls a few pixels of height a rotation whenever the viewport is
// near enough to square for them to cross it -- and then pins the
// slides and turns snapping off for a second over the chrome sliding
// away, which is the one thing this is here to tell a turn apart from.
if (before.w === now.w) return false;
return (before.w > before.h) !== (now.w > now.h);
};
// Holds the slides at `shape` -- the size they have as the turn starts --
// until the viewport has finished moving. Where a turn arrives in one
// step, which is most of them, this lasts a few frames and changes
// nothing anybody sees.
const pinTurn = function(shape) {
turnPin = { w: shape.w, h: shape.h };
const root = getRoot();
if (!root) return;
root.style.setProperty('--turn-w', `${turnPin.w}px`);
root.style.setProperty('--turn-h', `${turnPin.h}px`);
root.classList.add('is-turning');
};
const releaseTurn = function() {
if (!turnPin) return;
turnPin = null;
const root = getRoot();
if (!root) return;
root.classList.remove('is-turning');
root.style.removeProperty('--turn-w');
root.style.removeProperty('--turn-h');
};
// Re-anchors the scroll position on the currently active video after the
// viewport changes. The active slide is resolved by id (not by a possibly
// stale scroll position) so an orientation change always keeps the same
// video playing/focused rather than snapping to a neighbour.
const realignToActive = function() {
if (stepCount() === 0) return;
let index = state.feedActiveIndex;
if (state.feedActiveVideoId != null) {
const found = (state.loadedVideos || [])
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
if (found >= 0) index = Math.floor(found / paneCount());
}
index = clampIndex(index);
if (index < 0) return;
state.feedActiveIndex = index;
const h = slideHeight();
const start = windowBounds(index).start;
// Only write what actually differs. This runs on every frame of a
// turn, and a spacer height or a scrollTop written back unchanged
// still costs a layout and still sets the snap container off again --
// the page fighting itself at the moment it is busiest.
const spacer = getTopSpacer();
const spacerHeight = `${start * h}px`;
if (spacer && spacer.style.height !== spacerHeight) spacer.style.height = spacerHeight;
const scroller = getScroller();
const top = index * h;
if (scroller && Math.abs(scroller.scrollTop - top) > 0.5) scroller.scrollTop = top;
const activeSlide = slidesByIndex.get(index);
// The title only needs re-measuring when the screen it has to fit in
// changed size, not sixty times while it doesn't.
if (activeSlide && h !== lastMeasuredHeight) {
lastMeasuredHeight = h;
measureFeedTitle(activeSlide);
}
};
// Ends the realign window, and `realign` says whether to re-anchor on the
// way out. A window that closes on its own does -- holding the reader's
// video in place is what it was opened for. One cut short by a swipe must
// not, or closing it would undo the very swipe that closed it.
const closeSettle = function(realign) {
if (resizeSettleRaf) {
cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = null;
}
if (resizeSettleTimer) {
clearTimeout(resizeSettleTimer);
resizeSettleTimer = null;
}
releaseTurn();
if (realign) realignToActive();
suppressScroll = false;
lastShape = viewportShape();
const after = settleDone;
settleDone = null;
if (after) after();
};
// Ends the realign window: one last realign, then real swipes count again.
const finishSettle = function() {
closeSettle(true);
};
// Watches the viewport settle, frame by frame, holding the active video in
// place the whole way.
const settleStep = function() {
resizeSettleRaf = null;
if (!state.feedOpen || state.feedActiveIndex < 0) {
suppressScroll = false;
return;
}
// The viewport's own height, not the pinned one: the pin is a fixed
// number and would look settled from the first frame.
const shape = viewportShape();
if (shape.h === settleHeight) settleStableFrames += 1;
else {
settleHeight = shape.h;
settleStableFrames = 0;
}
// A turn arrives in several steps, and the first of them can be small
// enough to pass for the chrome sliding; it is the shape it ends up in
// that says what it was.
if (!turnPin && !turnResolved && isTurn(settleFromShape, shape)) {
pinTurn(settleFromShape);
}
// A turn the device announced is not over because the viewport paused.
// It arrives in steps, and the gap between two of them is routinely
// longer than the handful of frames that stands in for "stopped" --
// which is how the pin came off mid-turn and let the picture take an
// intermediate shape, the one thing it is here to prevent. So while
// the announcement stands the pin holds, and the frame count is left
// to end the pins nothing announced: a window dragged across the
// square, where there is no signal to go on and no turn to ruin.
const announced = Date.now() - turnSignalAt < TURN_SIGNAL_MS;
// The viewport has stopped moving, so what it is now is better than
// what the pin guessed it would be. Letting go here, with the realign
// below in the same frame, is the one step the picture takes.
if (turnPin && !announced && settleStableFrames >= TURN_HOLD_FRAMES) {
releaseTurn();
turnResolved = true;
}
realignToActive();
// The window may not conclude mid-turn either: closing it releases the
// pin on the way out, which would put the picture through the same
// intermediate shape by the other door. An announced turn therefore
// holds the window open too, and SETTLE_MAX_MS -- longer than the
// announcement -- is what still guarantees it ends.
if ((announced || settleStableFrames < SETTLE_STABLE_FRAMES) &&
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
resizeSettleRaf = requestAnimationFrame(settleStep);
return;
}
// One more frame, so a re-snap the container has already scheduled
// lands inside the window rather than just after it.
resizeSettleRaf = requestAnimationFrame(finishSettle);
};
// The device announcing the turn: dim first, measure after. Nothing about
// the viewport has changed yet at this point, so the reel is already on
// its way out when the first new shape lands.
const onOrientation = function() {
turnSignalAt = Date.now();
turnResolved = false;
if (state.feedOpen && state.feedActiveIndex >= 0 && !turnPin) {
pinTurn(viewportShape());
}
onResize();
};
const onResize = function() {
if (!state.feedOpen || state.feedActiveIndex < 0) return;
// Suppress scroll handling while we realign so the container's re-snap
// doesn't flip the active video, then re-enable it once layout settles.
suppressScroll = true;
// The shape to compare against is the one from before the turn
// started, not the one from the previous step of it.
if (!resizeSettleTimer) {
settleFromShape = lastShape || viewportShape();
turnResolved = false;
}
settleStartedAt = Date.now();
settleStableFrames = 0;
settleHeight = -1;
lastMeasuredHeight = -1;
if (!turnPin && !turnResolved && isTurn(settleFromShape, viewportShape())) {
pinTurn(settleFromShape);
}
realignToActive();
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = requestAnimationFrame(settleStep);
// Frames stop in a hidden tab, which would leave the window open for
// as long as the tab stays away; the clock closes it regardless.
if (resizeSettleTimer) clearTimeout(resizeSettleTimer);
resizeSettleTimer = setTimeout(finishSettle, SETTLE_MAX_MS + 500);
};
// ------------------------------------------------------------------
// 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:
// 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
// the window" means its next/previous buttons -- which are the same
// controls that appear on a lock screen, on headphones and on media keys.
//
// Those buttons cannot simply advance the feed, because picture-in-picture
// is bound to one element and the feed destroys a step's videos when it
// moves on -- the window would close. So the window is given a surface of
// its own: one <video> that outlives any step, whose source is swapped as
// the reader moves. The feed follows along underneath, so leaving the
// window puts them where they expect to be.
// ------------------------------------------------------------------
// The element in the window is the pane's own video, not a hidden stand-in.
// That matters for the automatic case: a browser will only carry a video
// out to a window on its own if that video is already playing, and only the
// panes are. A hidden surface can be put there by an explicit request, but
// an explicit request needs a user gesture and a tab switch has none --
// which is exactly why opening it by hand worked and switching tabs did
// not.
//
// Stepping is what a pane's video normally cannot survive, since the feed
// destroys a step's videos when it moves on. So 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, and the feed is rebuilt around
// it on the way back.
let pipPane = null;
const pipPinned = function(slide) {
return !!(pipPane && slide && slide.contains(pipPane));
};
App.feed.pipPinned = pipPinned;
// Points a pane at another video, in place.
const pipRebind = function(pane, v) {
const video = pane.querySelector('.feed-video');
if (!video || !v) return;
if (video._hlsPlayer) {
video._hlsPlayer.destroy();
video._hlsPlayer = null;
}
video._tearingDown = true;
video.pause();
video.removeAttribute('src');
video.load();
pane.classList.remove('is-loaded');
pane._videoData = v;
pane._formatOverride = null;
pane._awaitingFormats = false;
pane._failed = false;
pane.dataset.videoId = v.id;
// The pane really is this video now, so what it says has to match --
// it is on screen again the moment the window closes.
const titleText = pane.querySelector('.feed-title-text');
if (titleText) titleText.textContent = v.title || '';
const poster = pane.querySelector('.feed-poster');
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
loadSlideSource(pane, v, true);
setPipMetadata(v);
};
// Moves a video at a time. With a window open that is the pinned pane; with
// none it is the feed itself, since these handlers are also the media keys.
const pipStep = function(delta) {
const videos = state.loadedVideos || [];
if (!videos.length) return;
if (!pipPane) {
const step = clampIndex(state.feedActiveIndex + delta);
if (step < 0 || step === state.feedActiveIndex) return;
const scroller = getScroller();
if (scroller) scroller.scrollTo({ top: step * slideHeight(), behavior: 'smooth' });
return;
}
const current = videos.findIndex((v) => String(v.id) === String(pipPane.dataset.videoId));
const next = Math.min(videos.length - 1, Math.max(0, (current < 0 ? 0 : current) + delta));
if (next === current) return;
pipRebind(pipPane, videos[next]);
// Keep pulling pages if the window is walking towards the end.
prefetchIfNeeded(Math.floor(next / paneCount()));
};
App.feed.pipStep = pipStep;
// What the window, the lock screen and the media keys show.
const setPipMetadata = function(v) {
if (!navigator.mediaSession || !window.MediaMetadata) return;
try {
navigator.mediaSession.metadata = new window.MediaMetadata({
title: v.title || 'Video',
artist: v.uploader || '',
artwork: v.thumb ? [{ src: v.thumb }] : []
});
} catch (err) { /* decoration; never fail playback for it */ }
};
const bindMediaSession = function() {
if (!navigator.mediaSession || !navigator.mediaSession.setActionHandler) return;
const set = (action, handler) => {
try { navigator.mediaSession.setActionHandler(action, handler); }
catch (err) { /* this browser doesn't offer that one */ }
};
set('nexttrack', () => pipStep(1));
set('previoustrack', () => pipStep(-1));
};
const unbindMediaSession = function() {
if (!navigator.mediaSession || !navigator.mediaSession.setActionHandler) return;
['nexttrack', 'previoustrack'].forEach((action) => {
try { navigator.mediaSession.setActionHandler(action, null); } catch (err) { /* ignore */ }
});
};
// enterpictureinpicture bubbles, so one pair of listeners covers every pane
// however the window was opened -- the button, or the browser doing it
// itself when the tab is hidden.
const bindPipTracking = function() {
document.addEventListener('enterpictureinpicture', (event) => {
const pane = event.target.closest && event.target.closest('.feed-pane');
if (!pane) return;
pipPane = pane;
bindMediaSession();
setPipMetadata(pane._videoData || {});
});
document.addEventListener('leavepictureinpicture', (event) => {
if (!pipPane) return;
const pane = event.target.closest && event.target.closest('.feed-pane');
// Deliberately not an exact match on the pinned pane. A pin that is
// never released leaves the feed unable to recycle that slide for
// the rest of the session, so any leave that finds nothing left in
// a window releases it -- being wrong here costs a rebuild, being
// stuck costs a frozen feed.
if (pane && pane !== pipPane && App.customPlayer.pipElement()) return;
const landed = (pipPane.dataset && pipPane.dataset.videoId) || null;
pipPane = null;
unbindMediaSession();
if (navigator.mediaSession) navigator.mediaSession.metadata = null;
if (!state.feedOpen) return;
// The pinned pane was walked out of its step and now shows a video
// that belongs to another one -- two panes can even name the same
// video while it is pinned. Rebuilding around where it ended puts
// every step back in order.
if (landed) state.feedActiveVideoId = landed;
rebuildLayout();
});
};
App.feed.openPip = async function(pane) {
if (!App.customPlayer.supportsPiP()) return false;
const target = pane || (slidesByIndex.get(state.feedActiveIndex) &&
panesOf(slidesByIndex.get(state.feedActiveIndex))[0]);
const video = target && target.querySelector('.feed-video');
if (!video || video.disablePictureInPicture) return false;
return App.customPlayer.enterPiP(video);
};
// ------------------------------------------------------------------
// 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() {
const resume = function() {
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(() => {});
}
});
};
if (!state.feedOpen || state.feedActiveIndex < 0) {
resume();
return;
}
settleDone = resume;
onResize();
};
// 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 (App.customPlayer.pipElement()) {
await App.customPlayer.exitPiP();
}
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
//
// Leaving the tab while reels is playing should carry the video out with
// the reader. With panes there are several candidates and only one window,
// so the choice is made here rather than left to whichever pane's handler
// fires first: the panel you can hear, or the first one if they are all
// muted.
// ------------------------------------------------------------------
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);
if (!slide) return null;
const panes = panesOf(slide);
if (!panes.length) return null;
const audible = panes.find((pane) => !pane._muted);
const chosen = audible || panes[0];
return chosen ? chosen.querySelector('.feed-video') : null;
};
// Marks the chosen video and clears every other, so the browser's own
// automatic handling targets the same one this would.
const updateAutoPiPTarget = function() {
const wanted = state.feedOpen ? autoPipVideo() : null;
slidesByIndex.forEach((slide) => {
panesOf(slide).forEach((pane) => {
const video = pane.querySelector('.feed-video');
if (video) App.customPlayer.setAutoPiP(video, video === wanted);
});
});
};
App.feed.updateAutoPiPTarget = updateAutoPiPTarget;
// The fallback for browsers that ignore the attribute but would allow the
// request. It fails without a user gesture in most of them, which is why
// the attribute above is the real mechanism.
const onFeedHidden = function() {
if (!state.feedOpen) return;
if (document.visibilityState !== 'hidden') return;
if (!App.customPlayer.supportsPiP()) return;
if (App.customPlayer.pipElement()) return;
const video = autoPipVideo();
if (!video || video.paused || video.ended || video.disablePictureInPicture) return;
// The browser may already be doing this itself, from the attribute
// updateAutoPiPTarget put on this very element; the request is only for
// where the attribute is ignored but a request would be allowed.
App.customPlayer.enterPiP(video);
};
let autoPipBound = false;
App.feed.isOpen = function() {
return !!state.feedOpen;
};
// Re-applies the on-video-end preference to every rendered slide so toggling
// the setting takes effect immediately, without needing to reopen the feed.
App.feed.applyEndBehavior = function() {
const loop = shouldLoop();
slidesByIndex.forEach((slide) => {
panesOf(slide).forEach((pane) => {
const video = pane.querySelector('.feed-video');
if (video) video.loop = loop;
});
});
};
// Called whenever new video JSON is appended (e.g. after a prefetch). Lets
// the open feed pick up newly buffered slides and extend its window if the
// active slide is near the end.
App.feed.renderSlides = function() {
if (!state.feedOpen || state.feedActiveIndex < 0) return;
setActive(state.feedActiveIndex);
};
App.feed.reset = function() {
slidesByIndex.forEach((slide) => {
teardownSlide(slide);
slide.remove();
});
slidesByIndex.clear();
forgetPeeks();
releaseTurn();
resumeTimes.clear();
state.feedActiveIndex = -1;
state.feedActiveVideoId = null;
suppressScroll = false;
if (resizeSettleRaf) {
cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = null;
}
if (resizeSettleTimer) {
clearTimeout(resizeSettleTimer);
resizeSettleTimer = null;
}
const spacer = getTopSpacer();
if (spacer) spacer.style.height = '0px';
const scroller = getScroller();
if (scroller) scroller.scrollTop = 0;
};
App.feed.open = function(startVideoId) {
const container = getRoot();
const scroller = getScroller();
if (!container || !scroller) return;
state.feedOpen = true;
if (App.player && typeof App.player.close === 'function') {
// fromPopState: true suppresses the player's own history.back()
// -- this is an incidental "make sure it's closed" call when
// switching to Reels view, not the user pressing the player's
// close button, so it must not silently consume a back-button
// entry out from under real browser navigation.
App.player.close({ fromPopState: true });
}
container.classList.add('open');
container.setAttribute('aria-hidden', 'false');
lastShape = viewportShape();
document.body.classList.add('feed-mode-open');
document.body.style.overflow = 'hidden';
if (!scrollBound) {
scroller.addEventListener('scroll', onScroll, { passive: true });
// How the realign window tells a swipe from the container
// re-anchoring itself (see suppressScroll): a scroll with one of
// these behind it is the reader's, and outranks the window.
// Only input that means to scroll counts -- a finger that has
// moved, not one that has merely landed, since a tap must not
// sign for the re-anchor that follows it.
const noteInput = () => { lastInputAt = Date.now(); };
['wheel', 'touchmove'].forEach((type) => {
scroller.addEventListener(type, noteInput, { passive: true });
});
// Arrow keys and Page Up/Down scroll the container too, and the
// keystroke lands on whatever has focus rather than the scroller.
window.addEventListener('keydown', noteInput, { passive: true });
window.addEventListener('resize', onResize);
// `orientationchange` fires before the new metrics are readable and
// the visual viewport moves on its own as the browser's chrome
// slides away, so both open the realign window too -- the watcher
// below waits for the height to stop moving either way.
window.addEventListener('orientationchange', onOrientation);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onResize);
}
scrollBound = true;
}
if (!autoPipBound) {
document.addEventListener('visibilitychange', onFeedHidden);
window.addEventListener('pagehide', onFeedHidden);
bindPipTracking();
autoPipBound = true;
}
if (!hudActivityBound) {
container.addEventListener('mousemove', wakeHud, { passive: true });
// A press on the video is how a swipe begins, and waking on that
// flashed the HUD at the start of every drag -- a press that turns
// out to be a tap wakes it through the gesture layer's onSingleTap
// instead. A press on a control is not a swipe, and the control it
// lands on is one the reader can't see while the HUD is hidden.
container.addEventListener('pointerdown', (event) => {
const target = event.target;
if (target && target.closest &&
target.closest('button, input, a, .feed-timeline, .feed-format-menu')) {
wakeHud();
}
}, { passive: true });
hudActivityBound = true;
}
// Start from whichever grid video the user was looking at.
let startIndex = 0;
if (startVideoId != null) {
const found = (state.loadedVideos || [])
.findIndex((v) => String(v.id) === String(startVideoId));
if (found >= 0) startIndex = Math.floor(found / paneCount());
}
// Force a fresh activation even if the index happens to match.
state.feedActiveIndex = -1;
setActive(startIndex);
scroller.scrollTop = startIndex * slideHeight();
App.feed.updateToggleButton();
App.feed.updateMuteButton();
// The scroll assignment above fires a scroll event, which puts the HUD
// away; the controls should be up when the feed opens, so this waits
// for that to land (scroll events run before animation frames).
requestAnimationFrame(wakeHud);
};
App.feed.close = function() {
const container = getRoot();
if (!container) return;
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) {
clearTimeout(hudIdleTimer);
hudIdleTimer = null;
}
setHudIdle(false);
updateAutoPiPTarget(); // feedOpen is false now, so this clears them
unbindMediaSession();
if (pipPane && App.customPlayer.pipElement()) {
App.customPlayer.exitPiP();
}
pipPane = null;
slidesByIndex.forEach((slide) => panesOf(slide).forEach(destroySlidePlayback));
container.classList.remove('open');
container.setAttribute('aria-hidden', 'true');
document.body.classList.remove('feed-mode-open');
document.body.style.overflow = 'auto';
// The grid reveals a page at a time and is back in charge of the
// buffer: the feed's deeper one was for swipes eating N videos each.
if (App.videos.setPrefetchDepth) App.videos.setPrefetchDepth(1);
App.feed.updateToggleButton();
};
App.feed.toggle = function() {
if (state.feedOpen) {
App.feed.close();
} else {
const focusedId = App.videos && typeof App.videos.getFocusedVideoId === 'function'
? App.videos.getFocusedVideoId()
: null;
App.feed.open(focusedId);
}
};
// The feed-wide control now sets every panel at once; each panel still has
// its own switch for when they should differ.
App.feed.toggleMute = function() {
state.feedMuted = !state.feedMuted;
slidesByIndex.forEach((slide) => {
panesOf(slide).forEach((pane) => {
pane._muted = state.feedMuted;
if (pane._syncMute) pane._syncMute();
});
});
// Unmuting everything is still one panel's worth of sound where the
// device allows only one; the rest keep the picture.
limitAudibleToOne();
resumeActivePanes();
App.feed.updateMuteButton();
};
App.feed.updateToggleButton = function() {
const btn = document.getElementById('mode-toggle-btn');
const icon = document.getElementById('mode-toggle-icon');
if (!btn || !icon) return;
const open = !!state.feedOpen;
btn.setAttribute('aria-pressed', open ? 'true' : 'false');
const label = open ? 'Back to grid' : 'Switch to Reels view';
btn.title = label;
icon.alt = label;
icon.src = open
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/squares-2x2.svg'
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg';
};
App.feed.updateMuteButton = function() {
const icon = inRoot('feed-mute-icon');
if (!icon) return;
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-wave.svg';
icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
// Pulse a brass ring while muted to hint "tap to hear sound".
const btn = inRoot('feed-mute-btn');
if (btn) btn.classList.toggle('is-muted', !!state.feedMuted);
};
})();