Files
jacuzzi/frontend/js/feed.js
Simon f0df53365d Split the reels view into panels
A panel can be split to the right or below, and the panel that appears can
be split again, so any arrangement is reachable. The layout is a binary
tree and the leaves, read in order, are the panels of a step.

Scrolling drives all of them: with N panels a step covers N videos and one
swipe advances the whole set. That runs through every index in the feed --
opening on a video, realigning after a rotation, the prefetch buffer, the
render window -- all of which now convert between a video and the step
that holds it.

Each panel has its own sound, so two can play at once if that is what you
want. A new panel inherits the feed-wide setting rather than starting
muted, so a step built later doesn't disagree with what is already on
screen, and the feed-wide button reads as muted only while every panel is.

Two things fall out of panels that are worth knowing. The render window
narrows as panels are added -- five steps ahead of a four-panel split
would be twenty live <video> elements -- so splitting does not multiply
decoding. And splitting rebuilds around the video you are on rather than
keeping it in the panel you split from: steps are aligned to the panel
count, so it stays on screen but not necessarily first.

The per-video helpers were already written against an element holding one
video's controls, so they took a panel unchanged; a slide became the
container that fans out over them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 18:23:51 +00:00

1101 lines
47 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.
const PRELOAD_COUNT = 2; // slides ahead kept with a live <video> playing/preloaded
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;
// 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;
const scheduleHudHide = function() {
if (hudIdleTimer) clearTimeout(hudIdleTimer);
hudIdleTimer = setTimeout(() => {
hudIdleTimer = null;
if (!state.feedOpen) return;
document.body.classList.add('feed-hud-idle');
// The quality menu only fades with the rest of the HUD if we close
// it: it's an opened popover, not a permanently mounted control.
document.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
}, HUD_IDLE_MS);
};
const wakeHud = function() {
document.body.classList.remove('feed-hud-idle');
if (state.feedOpen) scheduleHudHide();
};
const getScroller = () => document.getElementById('feed-scroll');
const getSentinel = () => document.getElementById('feed-sentinel');
const getTopSpacer = () => document.getElementById('feed-top-spacer');
const slideHeight = function() {
const scroller = getScroller();
return (scroller && scroller.clientHeight) || window.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' });
};
// 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;
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;
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(slide, video, videoData) {
const cleanups = [];
const escalator = App.customPlayer.createSkipEscalator();
cleanups.push(() => escalator.destroy());
const doSkip = (direction) => {
const amount = App.customPlayer.skip(video, direction, escalator);
flashFeed(slide, `${direction === 'forward' ? '+' : '-'}${amount}s`);
wakeHud();
};
const pipBtn = slide.querySelector('.feed-pip-btn');
if (pipBtn) {
pipBtn.hidden = !App.customPlayer.supportsPiP();
const onClick = async (event) => {
event.stopPropagation();
await App.customPlayer.togglePiP(video);
};
pipBtn.addEventListener('click', onClick);
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
}
cleanups.push(App.customPlayer.bindAutoPiP(video));
const formatBtn = slide.querySelector('.feed-format-btn');
const formatMenu = slide.querySelector('.feed-format-menu');
const onFormatPick = (fmt) => {
slide._formatOverride = fmt;
const t = video.currentTime;
if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t);
// Tear down the current source (mirrors destroySlidePlayback's
// hls/video reset) before reloading with the new format -- this
// is a live in-place reload, not a fresh never-loaded slide, so
// 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();
slide.classList.remove('is-loaded');
loadSlideSource(slide, videoData, true);
};
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
{ getCurrentUrl: () => slide._activeUrl || '', onOpen: wakeHud });
let destroyFormatMenu = bindFormats();
cleanups.push(() => destroyFormatMenu());
// A slide 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 slide was torn down (or rebound) in the meantime.
if (!meta || slide._sharedControlCleanups !== cleanups) return;
destroyFormatMenu();
destroyFormatMenu = bindFormats();
});
}
cleanups.push(App.customPlayer.attachGestures(slide, {
onSingleTap: wakeHud,
onDoubleTapLeft: () => doSkip('back'),
onDoubleTapRight: () => doSkip('forward'),
ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline'
}));
slide._sharedControlCleanups = cleanups;
};
const loadSlideSource = function(slide, videoData, autoplay) {
const video = slide.querySelector('.feed-video');
if (!video) return;
if (slide.classList.contains('is-loaded')) {
if (autoplay) {
video.muted = 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);
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 = slide._muted !== false;
video.preload = 'auto';
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 hls = new HlsLib();
video._hlsPlayer = hls;
hls.loadSource(streamUrl);
hls.attachMedia(video);
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;
teardownAllSlides();
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);
const scroller = getScroller();
if (scroller) scroller.scrollTop = step * slideHeight();
};
const teardownAllSlides = function() {
slidesByIndex.forEach((slide) => {
teardownSlide(slide);
slide.remove();
});
slidesByIndex.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 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);
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();
refreshFeedMuteState();
});
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;
};
const bindPaneTools = function(pane, node) {
const closeBtn = pane.querySelector('.feed-pane-close');
closeBtn.hidden = paneCount() <= 1;
pane.querySelector('.feed-pane-split-right').addEventListener('click', (event) => {
event.stopPropagation();
splitPane(node, 'row');
});
pane.querySelector('.feed-pane-split-down').addEventListener('click', (event) => {
event.stopPropagation();
splitPane(node, 'col');
});
closeBtn.addEventListener('click', (event) => {
event.stopPropagation();
closePane(node);
});
};
// 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;
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. Steps ahead/behind are counted in steps,
// but each one now costs a whole step's worth of <video> elements, so
// splitting four ways should not put four times as many in the document:
// the window narrows as panes are added.
//
// 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();
return {
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
end: Math.min(stepCount() - 1,
activeIndex + Math.max(1, 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`;
};
// Once the active slide gets within PREFETCH_PAGES of the end of the loaded
// JSON, pull the next page so the buffer stays ahead of the rendered window.
const prefetchIfNeeded = function(activeIndex) {
const total = (state.loadedVideos || []).length;
const bufferAhead = total - 1 - ((activeIndex + 1) * paneCount() - 1);
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
&& 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_COUNT, 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) {
panesOf(activeSlide).forEach((pane) => {
loadSlideSource(pane, pane._videoData, true);
requestAnimationFrame(() => measureFeedTitle(pane));
});
}
slidesByIndex.forEach((slide, i) => {
if (i === clamped) return;
const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
panesOf(slide).forEach((pane) => {
if (i > clamped && i <= clamped + preloadAhead) {
loadSlideSource(pane, pane._videoData, false);
} 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')) {
destroySlidePlayback(pane);
}
});
});
prefetchIfNeeded(clamped);
};
const onScroll = function() {
wakeHud();
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;
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
if (index < 0) return;
if (index !== state.feedActiveIndex) {
setActive(index);
}
});
};
// 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;
const spacer = getTopSpacer();
if (spacer) spacer.style.height = `${start * h}px`;
const scroller = getScroller();
if (scroller) scroller.scrollTop = index * h;
const activeSlide = slidesByIndex.get(index);
if (activeSlide) measureFeedTitle(activeSlide);
};
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;
realignToActive();
// Orientation changes can settle over more than one frame (the visual
// viewport and the scroll-snap re-anchor in stages); realign again once
// layout has settled, then stop suppressing real swipes.
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = requestAnimationFrame(() => {
realignToActive();
resizeSettleRaf = requestAnimationFrame(() => {
resizeSettleRaf = null;
suppressScroll = 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();
resumeTimes.clear();
state.feedActiveIndex = -1;
state.feedActiveVideoId = null;
suppressScroll = false;
if (resizeSettleRaf) {
cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = 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 = document.getElementById('feed-view');
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');
document.body.classList.add('feed-mode-open');
document.body.style.overflow = 'hidden';
if (!scrollBound) {
scroller.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
scrollBound = true;
}
if (!hudActivityBound) {
container.addEventListener('mousemove', wakeHud, { passive: true });
container.addEventListener('pointerdown', wakeHud, { passive: true });
container.addEventListener('touchstart', 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();
wakeHud();
};
App.feed.close = function() {
const container = document.getElementById('feed-view');
if (!container) return;
state.feedOpen = false;
if (hudIdleTimer) {
clearTimeout(hudIdleTimer);
hudIdleTimer = null;
}
document.body.classList.remove('feed-hud-idle');
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';
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();
});
});
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 = document.getElementById('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 = document.getElementById('feed-mute-btn');
if (btn) btn.classList.toggle('is-muted', !!state.feedMuted);
};
})();