Files
jacuzzi/frontend/js/feed.js
Simon 138c3224de Add modern UI enhancements over the classic redesign
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>
2026-06-30 08:25:37 +00:00

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);
};
})();