dynamic video loading

This commit is contained in:
Simon
2026-06-19 22:03:49 +00:00
parent 95b75bf999
commit a97f7e7b0f
5 changed files with 240 additions and 111 deletions

View File

@@ -3,10 +3,49 @@ App.feed = App.feed || {};
(function() {
const state = App.state;
let observer = null;
let sentinelObserver = null;
// 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;
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));
};
const destroySlidePlayback = function(slide) {
const video = slide.querySelector('.feed-video');
@@ -82,8 +121,6 @@ App.feed = App.feed || {};
timeline.addEventListener('pointercancel', stopScrubbing);
};
const PRELOAD_COUNT = 2;
const loadSlideSource = function(slide, videoData, autoplay) {
const video = slide.querySelector('.feed-video');
if (!video) return;
@@ -129,54 +166,24 @@ App.feed = App.feed || {};
}
};
const activateSlide = function(slide, videoData) {
loadSlideSource(slide, videoData, true);
};
// Keeps exactly [active, active+1, active+2] loaded: preloads the next
// PRELOAD_COUNT slides and tears down everything else. Centralizing this
// here (instead of letting each slide's own enter/leave intersection
// entry decide) avoids a race where the initial batch of "not
// intersecting" entries for not-yet-visible slides undoes the preload
// we just triggered for them.
const syncWindow = function(activeSlide) {
// 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;
const slides = Array.from(scroller.querySelectorAll('.feed-slide'));
const index = slides.indexOf(activeSlide);
if (index === -1) return;
if (!scroller) return null;
const keep = new Set();
for (let i = index; i <= index + PRELOAD_COUNT && i < slides.length; i++) {
keep.add(slides[i]);
}
slides.forEach((slide) => {
if (keep.has(slide)) return;
if (slide.classList.contains('is-loaded')) {
destroySlidePlayback(slide);
}
});
for (let i = index + 1; i <= index + PRELOAD_COUNT && i < slides.length; i++) {
loadSlideSource(slides[i], slides[i]._videoData, false);
}
};
App.feed.isOpen = function() {
return !!state.feedOpen;
};
App.feed.renderSlides = function() {
const scroller = getScroller();
if (!scroller) return;
(state.loadedVideos || []).forEach((v) => {
if (state.feedRenderedIds.has(v.id)) return;
state.feedRenderedIds.add(v.id);
const slide = document.createElement('div');
slide.className = 'feed-slide';
slide.dataset.videoId = v.id;
const uploaderText = v.uploader || '';
slide.innerHTML = `
const slide = document.createElement('div');
slide.className = 'feed-slide';
slide.dataset.videoId = v.id;
slide.dataset.index = String(index);
slide._videoData = v;
slide._index = index;
const uploaderText = v.uploader || '';
slide.innerHTML = `
<img class="feed-poster" src="${v.thumb || ''}" alt="">
<video class="feed-video" muted playsinline webkit-playsinline loop preload="none"></video>
<div class="feed-info">
@@ -190,24 +197,140 @@ App.feed = App.feed || {};
</div>
</div>
`;
const poster = slide.querySelector('.feed-poster');
App.videos.attachNoReferrerRetry(poster);
slide._videoData = v;
bindTimeline(slide, slide.querySelector('.feed-video'));
scroller.insertBefore(slide, document.getElementById('feed-sentinel'));
if (observer) observer.observe(slide);
const poster = slide.querySelector('.feed-poster');
App.videos.attachNoReferrerRetry(poster);
bindTimeline(slide, slide.querySelector('.feed-video'));
// 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;
};
const removeSlide = function(index) {
const slide = slidesByIndex.get(index);
if (!slide) return;
destroySlidePlayback(slide);
slide.remove();
slidesByIndex.delete(index);
};
// 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) {
App.videos.loadVideos();
}
};
// 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;
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);
slidesByIndex.forEach((slide, i) => {
if (i === clamped) return;
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
loadSlideSource(slide, slide._videoData, false);
} else if (slide.classList.contains('is-loaded')) {
destroySlidePlayback(slide);
}
});
prefetchIfNeeded(clamped);
};
const onScroll = function() {
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
const scroller = getScroller();
if (!scroller) return;
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
if (index < 0) return;
if (index !== state.feedActiveIndex) {
setActive(index);
}
});
};
App.feed.reset = function() {
const onResize = function() {
if (!state.feedOpen || state.feedActiveIndex < 0) return;
const h = slideHeight();
const start = Math.max(0, state.feedActiveIndex - HISTORY_COUNT);
const spacer = getTopSpacer();
if (spacer) spacer.style.height = `${start * h}px`;
const scroller = getScroller();
document.querySelectorAll('.feed-slide').forEach((slide) => {
if (observer) observer.unobserve(slide);
if (scroller) scroller.scrollTop = state.feedActiveIndex * h;
};
App.feed.isOpen = function() {
return !!state.feedOpen;
};
// 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) => {
destroySlidePlayback(slide);
slide.remove();
});
state.feedRenderedIds.clear();
state.feedActiveSlide = null;
slidesByIndex.clear();
state.feedActiveIndex = -1;
const spacer = getTopSpacer();
if (spacer) spacer.style.height = '0px';
const scroller = getScroller();
if (scroller) scroller.scrollTop = 0;
};
@@ -221,46 +344,29 @@ App.feed = App.feed || {};
App.player.close();
}
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const slide = entry.target;
if (entry.isIntersecting && entry.intersectionRatio >= 0.6 && state.feedActiveSlide !== slide) {
const previousActive = state.feedActiveSlide;
state.feedActiveSlide = slide;
if (previousActive) previousActive.classList.remove('is-active');
slide.classList.add('is-active');
activateSlide(slide, slide._videoData);
syncWindow(slide);
}
});
}, { threshold: [0, 0.6, 1] });
}
if (!sentinelObserver) {
sentinelObserver = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
App.videos.loadVideos();
}
}, { threshold: 0.01 });
const sentinel = document.getElementById('feed-sentinel');
if (sentinel) sentinelObserver.observe(sentinel);
}
container.classList.add('open');
container.setAttribute('aria-hidden', 'false');
document.body.classList.add('feed-mode-open');
document.body.style.overflow = 'hidden';
App.feed.renderSlides();
if (startVideoId != null) {
const targetSlide = Array.from(scroller.querySelectorAll('.feed-slide'))
.find((slide) => slide.dataset.videoId === String(startVideoId));
if (targetSlide) scroller.scrollTop = targetSlide.offsetTop;
if (!scrollBound) {
scroller.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
scrollBound = true;
}
document.querySelectorAll('.feed-slide').forEach((slide) => observer.observe(slide));
// 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();
@@ -270,8 +376,7 @@ App.feed = App.feed || {};
const container = document.getElementById('feed-view');
if (!container) return;
state.feedOpen = false;
document.querySelectorAll('.feed-slide').forEach((slide) => destroySlidePlayback(slide));
state.feedActiveSlide = null;
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
container.classList.remove('open');
container.setAttribute('aria-hidden', 'true');
document.body.classList.remove('feed-mode-open');

View File

@@ -17,8 +17,7 @@ App.state = {
loadedVideos: [],
feedOpen: false,
feedMuted: true,
feedRenderedIds: new Set(),
feedActiveSlide: null,
feedActiveIndex: -1,
groupCursors: null
};