429 lines
17 KiB
JavaScript
429 lines
17 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;
|
|
|
|
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');
|
|
slide.classList.remove('is-active');
|
|
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;
|
|
}
|
|
video.pause();
|
|
video.removeAttribute('src');
|
|
video.load();
|
|
slide.classList.remove('is-loaded');
|
|
};
|
|
|
|
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 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;
|
|
}
|
|
slide.classList.add('is-loaded');
|
|
|
|
const resolved = App.videos.resolveStreamSource(videoData);
|
|
if (!resolved.url) return;
|
|
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
|
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
|
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}`;
|
|
const isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
|
const canUseHls = !!(window.Hls && window.Hls.isSupported());
|
|
|
|
video.muted = state.feedMuted;
|
|
video.preload = 'auto';
|
|
|
|
if (isHls && canUseHls) {
|
|
const hls = new window.Hls();
|
|
video._hlsPlayer = hls;
|
|
hls.loadSource(streamUrl);
|
|
hls.attachMedia(video);
|
|
hls.on(window.Hls.Events.ERROR, (event, data) => {
|
|
if (data && data.fatal && video._hlsPlayer === hls) {
|
|
hls.destroy();
|
|
video._hlsPlayer = null;
|
|
}
|
|
});
|
|
} else {
|
|
video.src = streamUrl;
|
|
}
|
|
|
|
if (autoplay) {
|
|
const playPromise = video.play();
|
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
|
}
|
|
};
|
|
|
|
// 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 = '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">
|
|
<h4 class="feed-title">${v.title || ''}</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);
|
|
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);
|
|
}
|
|
});
|
|
};
|
|
|
|
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();
|
|
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();
|
|
});
|
|
slidesByIndex.clear();
|
|
state.feedActiveIndex = -1;
|
|
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') {
|
|
App.player.close();
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 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();
|
|
};
|
|
|
|
App.feed.close = function() {
|
|
const container = document.getElementById('feed-view');
|
|
if (!container) return;
|
|
state.feedOpen = false;
|
|
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';
|
|
};
|
|
})();
|