Layered progressive polish on the warm classic + brass theme: - Card entrance animation on first mount (virtualizer-aware) - Cursor-tracking brass spotlight border on cards - Thumbnail skeleton shimmer until the poster paints - Hover video preview after a short dwell (only when formats resolved) - View Transition + blurred-poster ambient backdrop on player open - Favorite heart pop + expanding ring on add - ⌘K command palette (search, theme, density, reels, source/channel) - Scroll-progress bar + back-to-top FAB - Grid density toggle (comfortable/compact) - Reels HUD: serif title, brass scrubber, muted-state pulse All new motion respects prefers-reduced-motion; no JS/HTML structure changes to the core grid/feed. New glue lives in frontend/js/enhance.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
757 lines
31 KiB
JavaScript
757 lines
31 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 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) {
|
|
// 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>` : ''}
|
|
<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);
|
|
|
|
// 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;
|
|
};
|
|
|
|
const removeSlide = function(index) {
|
|
const slide = slidesByIndex.get(index);
|
|
if (!slide) return;
|
|
destroySlidePlayback(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) {
|
|
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;
|
|
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) => {
|
|
destroySlidePlayback(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') {
|
|
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;
|
|
}
|
|
|
|
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);
|
|
};
|
|
})();
|