A page used to be requested at the moment the reader hit the bottom, so the cards that appeared were empty frames filling in as their thumbnails arrived. Now the next page is fetched as soon as the current one renders and its thumbnails are decoded off-screen (low priority, started on an idle callback, so warming never competes with what's on screen), then held until the second-to-last row comes into view -- at which point the cards appear already finished, and the page after that starts loading. The two loaders are split into fetch-a-batch and commit-a-batch so the prefetcher and the on-demand path share them; a held batch is dropped when the result set changes (search, channel, filters). Three things this surfaced, all handled: loadVideos awaited the in-flight prefetch before raising state.isLoading, so every caller that arrived meanwhile sailed past the guard and started a duplicate page load, each one re-filling the viewport and calling back in. On a short desktop page that amplified until the tab stopped responding. A guard now covers the whole call. The reveal test can't be "the topmost visible card is in the last two rows": a desktop viewport shows several rows at once, so the reader would reach the end of the list without it ever passing. It's now "the second-to-last row has come into view", measured against the layout. The reels feed pulls pages through the same entry point, where the grid's scroll position means nothing -- it (and the Load more button) now pass force, which skips the hold. Verified in headless Chrome: page 2 fetched and all 12 of its thumbnails warmed while it was still hidden, grid still at 12 cards; revealed on the second-to-last row (phone: viewport bottom 4337px vs row at 4335px; desktop 4-col: 1704px vs 1687px) with its images already decoded; feed paging, search reset, rotation, momentum and playback all still good. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
888 lines
38 KiB
JavaScript
888 lines
38 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) document.body.classList.add('feed-hud-idle');
|
|
}, 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;
|
|
};
|
|
|
|
const clampIndex = function(index) {
|
|
const total = (state.loadedVideos || []).length;
|
|
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 feed title that scrolls horizontally when it overflows.
|
|
// Driven off the overflow distance so every title scrolls at the same
|
|
// gentle speed, matching the grid-card marquee (see App.videos.measureTitle).
|
|
const measureFeedTitle = function(slide) {
|
|
if (!slide) return;
|
|
const wrap = slide.querySelector('.feed-title');
|
|
const text = slide.querySelector('.feed-title-text');
|
|
if (!wrap || !text) return;
|
|
const overflow = text.scrollWidth - wrap.clientWidth;
|
|
if (overflow > 4) {
|
|
const distance = overflow + 16;
|
|
const MARQUEE_SPEED = 28; // px per second
|
|
const duration = Math.max(6, distance / MARQUEE_SPEED);
|
|
text.style.setProperty('--marquee-distance', `${distance}px`);
|
|
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
|
wrap.classList.add('is-marquee');
|
|
} else {
|
|
wrap.classList.remove('is-marquee');
|
|
text.style.removeProperty('--marquee-distance');
|
|
}
|
|
};
|
|
|
|
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);
|
|
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 = state.feedMuted;
|
|
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;
|
|
}
|
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
|
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
|
|
|
|
video.muted = state.feedMuted;
|
|
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.
|
|
const createSlide = function(index) {
|
|
if (slidesByIndex.has(index)) return slidesByIndex.get(index);
|
|
const v = (state.loadedVideos || [])[index];
|
|
if (!v) return null;
|
|
const scroller = getScroller();
|
|
if (!scroller) return null;
|
|
|
|
const slide = document.createElement('div');
|
|
slide.className = v.isLive ? 'feed-slide is-live' : 'feed-slide';
|
|
slide.dataset.videoId = v.id;
|
|
slide.dataset.index = String(index);
|
|
slide._videoData = v;
|
|
slide._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;
|
|
slide.innerHTML = `
|
|
<img class="feed-poster" src="${v.thumb || ''}" 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}"></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" hidden></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 = slide.querySelector('.feed-poster');
|
|
App.videos.attachNoReferrerRetry(poster);
|
|
const slideVideo = slide.querySelector('.feed-video');
|
|
bindTimeline(slide, slideVideo);
|
|
bindSharedControls(slide, 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(slide));
|
|
|
|
// 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;
|
|
if (!slide.classList.contains('is-active')) return;
|
|
advanceToNext(slide._index);
|
|
});
|
|
|
|
const favBtn = slide.querySelector('.feed-fav-btn');
|
|
if (favBtn && App.favorites) {
|
|
App.favorites.setButtonState(favBtn, App.favorites.getSet().has(favKey));
|
|
favBtn.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
App.favorites.toggle(v);
|
|
});
|
|
}
|
|
|
|
// Insert before the rendered slide with the next-highest index so DOM
|
|
// order always matches index order; fall back to the sentinel.
|
|
let ref = getSentinel();
|
|
let refIndex = Infinity;
|
|
slidesByIndex.forEach((el, i) => {
|
|
if (i > index && i < refIndex) {
|
|
refIndex = i;
|
|
ref = el;
|
|
}
|
|
});
|
|
scroller.insertBefore(slide, ref);
|
|
slidesByIndex.set(index, 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).
|
|
const teardownSlide = function(slide) {
|
|
destroySlidePlayback(slide);
|
|
if (Array.isArray(slide._sharedControlCleanups)) {
|
|
slide._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
|
slide._sharedControlCleanups = null;
|
|
}
|
|
};
|
|
|
|
const removeSlide = function(index) {
|
|
const slide = slidesByIndex.get(index);
|
|
if (!slide) return;
|
|
teardownSlide(slide);
|
|
slide.remove();
|
|
slidesByIndex.delete(index);
|
|
};
|
|
|
|
// Re-keys every rendered slide after `removedIndex` was spliced out of
|
|
// state.loadedVideos: indices past the hole shift down by one so
|
|
// slidesByIndex (and each slide's _index) stays aligned with the queue.
|
|
const reindexAfterRemoval = function(removedIndex) {
|
|
const entries = [];
|
|
slidesByIndex.forEach((slide, i) => entries.push([i, slide]));
|
|
slidesByIndex.clear();
|
|
entries.forEach(([i, slide]) => {
|
|
const ni = i > removedIndex ? i - 1 : i;
|
|
slide._index = ni;
|
|
slide.dataset.index = String(ni);
|
|
slidesByIndex.set(ni, slide);
|
|
});
|
|
};
|
|
|
|
// Drops a video that failed to load/resolve from the queue and pulls the
|
|
// next clip into its place. A failed *preload* neighbour leaves the active
|
|
// video playing untouched; a failed *active* clip is replaced in-place by
|
|
// the next one (the broken frame is removed and the next clip slides into
|
|
// the same scroll position, so playback advances without a visible jump).
|
|
const removeVideoFromQueue = function(videoId) {
|
|
const videos = state.loadedVideos || [];
|
|
const r = videos.findIndex((v) => String(v.id) === String(videoId));
|
|
if (r < 0) return;
|
|
const prevActive = state.feedActiveIndex;
|
|
|
|
// Drop the failed clip's feed slide element from the DOM, then its JSON
|
|
// from the queue, then re-key the remaining rendered slides.
|
|
removeSlide(r);
|
|
videos.splice(r, 1);
|
|
reindexAfterRemoval(r);
|
|
|
|
// 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;
|
|
}
|
|
|
|
// The active slot only moves when the removed clip was the active one
|
|
// (r === prevActive) or, defensively, sat before it.
|
|
let newActive = prevActive;
|
|
if (r < prevActive) newActive -= 1;
|
|
newActive = clampIndex(newActive);
|
|
|
|
state.feedActiveIndex = -1; // force setActive to re-promote the slot
|
|
setActive(newActive);
|
|
|
|
if (r <= prevActive) {
|
|
// Active clip failed: re-anchor scroll onto the clip that slid into
|
|
// its slot so the snap container stays pinned to the new active.
|
|
const scroller = getScroller();
|
|
if (scroller) scroller.scrollTop = newActive * slideHeight();
|
|
}
|
|
};
|
|
|
|
// 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(slide) {
|
|
if (!slide || slide._failed || !state.feedOpen) return;
|
|
const video = slide.querySelector('.feed-video');
|
|
if (video && video._tearingDown) return;
|
|
const id = slideVideoId(slide);
|
|
if (id == null) return;
|
|
slide._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.
|
|
const syncWindow = function(activeIndex) {
|
|
const total = (state.loadedVideos || []).length;
|
|
if (total === 0) return;
|
|
const start = Math.max(0, activeIndex - HISTORY_COUNT);
|
|
const end = Math.min(total - 1, activeIndex + RENDER_AHEAD);
|
|
|
|
slidesByIndex.forEach((slide, i) => {
|
|
if (i < start || i > end) 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;
|
|
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];
|
|
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) {
|
|
loadSlideSource(activeSlide, activeSlide._videoData, true);
|
|
requestAnimationFrame(() => measureFeedTitle(activeSlide));
|
|
}
|
|
|
|
slidesByIndex.forEach((slide, i) => {
|
|
if (i === clamped) return;
|
|
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
|
loadSlideSource(slide, slide._videoData, false);
|
|
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
|
// Recently-watched slides stay loaded but paused so scrolling
|
|
// back resumes seamlessly from where it was paused.
|
|
if (slide.classList.contains('is-loaded')) pauseSlide(slide);
|
|
} else if (slide.classList.contains('is-loaded')) {
|
|
destroySlidePlayback(slide);
|
|
}
|
|
});
|
|
|
|
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() {
|
|
const total = (state.loadedVideos || []).length;
|
|
if (total === 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 = found;
|
|
}
|
|
index = clampIndex(index);
|
|
if (index < 0) return;
|
|
state.feedActiveIndex = index;
|
|
const h = slideHeight();
|
|
const start = Math.max(0, index - HISTORY_COUNT);
|
|
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) => {
|
|
const video = slide.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 = found;
|
|
}
|
|
|
|
// 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) => destroySlidePlayback(slide));
|
|
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);
|
|
}
|
|
};
|
|
|
|
App.feed.toggleMute = function() {
|
|
state.feedMuted = !state.feedMuted;
|
|
document.querySelectorAll('.feed-video').forEach((video) => {
|
|
video.muted = state.feedMuted;
|
|
});
|
|
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);
|
|
};
|
|
})();
|