Files
jacuzzi/frontend/js/feed.js
Simon 6dac1028a3 Take a thumbnail out of the video when the listed one is gone
A favorite outlives its thumbnail. Sources sign their CDN paths and the
signature expires, so a video kept months ago has a `thumb` that 404s and
a card that is a grey box with a title under it. Asking the source page
for a fresh picture covers the case where the page still exists; nothing
covered the case where it doesn't.

The video does, because playback re-resolves from the page URL every time.
So once every route to the listed picture is exhausted, a frame a quarter
of the way in becomes the card's picture -- past the black lead-in, and a
fair guess at what the video looks like. It goes through our own proxy, so
the canvas can be read back, is scaled to card width and JPEG'd to a
handful of KB, and is kept: later mounts and later sessions paint it
without a request, instead of asking again for a picture that is known to
be gone.

Only for favorites, once per item, one at a time, and never for a live
stream or a manifest. Two things worth knowing, both found with the
browser rather than reasoned about: a browser fires `seeked` for its own
initial seek to zero, so taking the frame on the first one captures the
opening of every video; and the same item is on screen in more than one
place, so the captures are deduped by a promise rather than the first
asker getting the picture and the rest being told there isn't one.

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

2089 lines
94 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. 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
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) {
realignToActive();
return;
}
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;
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: one last realign, then real swipes count again.
const finishSettle = function() {
if (resizeSettleRaf) {
cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = null;
}
if (resizeSettleTimer) {
clearTimeout(resizeSettleTimer);
resizeSettleTimer = null;
}
releaseTurn();
realignToActive();
suppressScroll = false;
lastShape = viewportShape();
const after = settleDone;
settleDone = null;
if (after) after();
};
// 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);
}
// 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 && settleStableFrames >= TURN_HOLD_FRAMES) {
releaseTurn();
turnResolved = true;
}
realignToActive();
if (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 });
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);
};
})();