Split the reels view into panels

A panel can be split to the right or below, and the panel that appears can
be split again, so any arrangement is reachable. The layout is a binary
tree and the leaves, read in order, are the panels of a step.

Scrolling drives all of them: with N panels a step covers N videos and one
swipe advances the whole set. That runs through every index in the feed --
opening on a video, realigning after a rotation, the prefetch buffer, the
render window -- all of which now convert between a video and the step
that holds it.

Each panel has its own sound, so two can play at once if that is what you
want. A new panel inherits the feed-wide setting rather than starting
muted, so a step built later doesn't disagree with what is already on
screen, and the feed-wide button reads as muted only while every panel is.

Two things fall out of panels that are worth knowing. The render window
narrows as panels are added -- five steps ahead of a four-panel split
would be twenty live <video> elements -- so splitting does not multiply
decoding. And splitting rebuilds around the video you are on rather than
keeping it in the panel you split from: steps are aligned to the panel
count, so it stays on screen but not necessarily first.

The per-video helpers were already written against an element holding one
video's controls, so they took a panel unchanged; a slide became the
container that fans out over them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-10 18:23:51 +00:00
parent 6e5ab68a94
commit f0df53365d
2 changed files with 415 additions and 108 deletions

View File

@@ -1324,7 +1324,7 @@ body.theme-light .video-menu-btn {
}
/* Live streams can't be seeked, so hide the feed scrubber. */
.feed-slide.is-live .feed-timeline {
.feed-pane.is-live .feed-timeline {
display: none;
}
@@ -2158,6 +2158,9 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
flex: none;
}
/* One screenful. It holds the pane tree rather than a video directly: with
several panes, a step shows that many videos at once and one swipe advances
the whole set. */
.feed-slide {
position: relative;
width: 100%;
@@ -2165,6 +2168,34 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
height: 100dvh;
scroll-snap-align: start;
scroll-snap-stop: always;
overflow: hidden;
background: #000;
}
/* A branch of the tree: two children sharing the space, side by side or
stacked. Nesting these is what makes any arrangement reachable. */
.feed-split {
display: flex;
width: 100%;
height: 100%;
gap: 2px;
}
.feed-split.is-row { flex-direction: row; }
.feed-split.is-col { flex-direction: column; }
/* min-* is what stops a flex child refusing to shrink below its content. */
.feed-split > .feed-pane,
.feed-split > .feed-split {
flex: 1 1 0;
min-width: 0;
min-height: 0;
}
.feed-pane {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
@@ -2172,6 +2203,45 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
background: #000;
}
.feed-pane-tools {
position: absolute;
top: 12px;
right: 12px;
display: flex;
gap: 6px;
/* Above the video, below the right-hand controls it sits beside. */
z-index: 5;
}
.feed-pane-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid rgba(244, 232, 212, 0.18);
background: rgba(0, 0, 0, 0.45);
color: var(--text-primary);
font-size: 14px;
line-height: 1;
cursor: pointer;
backdrop-filter: blur(6px);
transition: background 0.2s ease, border-color 0.2s ease;
}
.feed-pane-btn:hover {
background: rgba(0, 0, 0, 0.7);
border-color: var(--border-hover);
}
.feed-pane-mute.is-muted {
border-color: var(--accent);
}
/* The HUD's idle fade takes the panel tools with it, like every other control. */
body.feed-hud-idle .feed-pane-tools {
opacity: 0;
transition: opacity 0.4s ease;
}
.feed-poster,
.feed-video {
position: absolute;
@@ -2185,7 +2255,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
transition: opacity 0.2s ease;
}
.feed-slide.is-loaded .feed-poster {
.feed-pane.is-loaded .feed-poster {
opacity: 0;
}
@@ -2310,6 +2380,22 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
}
/* Per-slide favorite (heart) button on the feed HUD right rail. */
/* The right-hand controls stack upwards from just above the caption, rather
than sitting at fixed distances from the bottom. A pane can be a third of
the viewport tall, and viewport-scale offsets put these outside it -- clipped
by the pane's own overflow and unreachable. */
.feed-pane .feed-fav-btn,
.feed-pane .feed-pip-btn,
.feed-pane .feed-format-btn {
position: absolute;
right: 12px;
bottom: auto;
top: 56px;
}
.feed-pane .feed-pip-btn { top: 100px; }
.feed-pane .feed-format-btn { top: 144px; }
.feed-fav-btn {
position: absolute;
top: auto;

View File

@@ -74,8 +74,14 @@ App.feed = App.feed || {};
return (scroller && scroller.clientHeight) || window.innerHeight || 1;
};
const clampIndex = function(index) {
// Steps, not videos: with N panes a step covers N videos at once.
const stepCount = function() {
const total = (state.loadedVideos || []).length;
return total === 0 ? 0 : Math.ceil(total / paneCount());
};
const clampIndex = function(index) {
const total = stepCount();
if (total === 0) return -1;
return Math.min(total - 1, Math.max(0, index));
};
@@ -315,7 +321,7 @@ App.feed = App.feed || {};
if (!video) return;
if (slide.classList.contains('is-loaded')) {
if (autoplay) {
video.muted = state.feedMuted;
video.muted = slide._muted !== false;
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
}
@@ -352,7 +358,7 @@ App.feed = App.feed || {};
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
const isHls = App.videos.classifySource(resolved).isHls;
video.muted = state.feedMuted;
video.muted = slide._muted !== false;
video.preload = 'auto';
video._tearingDown = false;
applyResume(video, videoData && videoData.id, resolved.isLive);
@@ -407,23 +413,101 @@ App.feed = App.feed || {};
// 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;
// ------------------------------------------------------------------
// Panes
//
// A step of the feed is one screenful, and it holds one or more panes. The
// arrangement is a tree: every pane can be split to the right or below,
// which replaces it with a two-child split, so any nesting is reachable.
// Leaves, read in order, are the panes of a step.
//
// Scrolling drives all of them at once: with N panes, step i shows videos
// i*N to i*N+N-1, and one swipe advances the whole set.
// ------------------------------------------------------------------
let paneTree = { type: 'pane' };
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 paneLeaves = function(node, out) {
out = out || [];
if (node.type === 'pane') out.push(node);
else node.children.forEach((child) => paneLeaves(child, out));
return out;
};
// Panes per step, which is the stride between one swipe and the next.
const paneCount = function() {
return paneLeaves(paneTree).length;
};
App.feed.paneCount = paneCount;
// Replaces `target` with a split holding it and a new pane.
const splitPane = function(target, direction) {
const replace = function(node) {
if (node === target) {
return { type: 'split', dir: direction, children: [target, { type: 'pane' }] };
}
if (node.type === 'split') {
node.children = node.children.map(replace);
}
return node;
};
paneTree = replace(paneTree);
rebuildLayout();
};
// Drops a pane, collapsing the split that held it so no split is ever left
// with a single child.
const closePane = function(target) {
if (paneCount() <= 1) return;
const prune = function(node) {
if (node.type !== 'split') return node;
const kept = node.children.filter((child) => child !== target).map(prune);
return kept.length === 1 ? kept[0] : Object.assign(node, { children: kept });
};
paneTree = prune(paneTree);
rebuildLayout();
};
// The pane count is the stride, so changing it renumbers every step. Rebuild
// around whatever video is playing so the reader keeps their place.
const rebuildLayout = function() {
const keepId = state.feedActiveVideoId;
teardownAllSlides();
const videos = state.loadedVideos || [];
let videoIndex = 0;
if (keepId != null) {
const found = videos.findIndex((v) => String(v.id) === String(keepId));
if (found >= 0) videoIndex = found;
}
state.feedActiveIndex = -1;
const step = Math.floor(videoIndex / paneCount());
setActive(step);
const scroller = getScroller();
if (scroller) scroller.scrollTop = step * slideHeight();
};
const teardownAllSlides = function() {
slidesByIndex.forEach((slide) => {
teardownSlide(slide);
slide.remove();
});
slidesByIndex.clear();
};
// Everything one video needs on screen. Identical to what a slide used to
// hold: the per-video helpers below take any element shaped like this, so a
// pane and the old single-video slide are interchangeable to them.
const buildPane = function(v, index) {
const pane = document.createElement('div');
pane.className = v.isLive ? 'feed-pane is-live' : 'feed-pane';
pane.dataset.videoId = v.id;
pane.dataset.index = String(index);
pane._videoData = v;
pane._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 = `
pane.innerHTML = `
<img class="feed-poster" alt="" loading="lazy" decoding="async">
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
${liveBadge}
@@ -433,6 +517,12 @@ App.feed = App.feed || {};
</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" role="menu" hidden></div>
<div class="feed-pane-tools">
<button class="feed-pane-btn feed-pane-mute" type="button" aria-label="Mute this panel"></button>
<button class="feed-pane-btn feed-pane-split-right" type="button" title="Add a panel to the right" aria-label="Add a panel to the right">⊞</button>
<button class="feed-pane-btn feed-pane-split-down" type="button" title="Add a panel below" aria-label="Add a panel below">⊟</button>
<button class="feed-pane-btn feed-pane-close" type="button" title="Close this panel" aria-label="Close this panel" hidden>✕</button>
</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>
@@ -445,17 +535,17 @@ App.feed = App.feed || {};
</div>
</div>
`;
const poster = slide.querySelector('.feed-poster');
const poster = pane.querySelector('.feed-poster');
App.videos.attachThumbnail(poster, v.thumb);
const slideVideo = slide.querySelector('.feed-video');
bindTimeline(slide, slideVideo);
bindSharedControls(slide, slideVideo, v);
const slideVideo = pane.querySelector('.feed-video');
bindTimeline(pane, slideVideo);
bindSharedControls(pane, 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));
slideVideo.addEventListener('error', () => markSlideFailed(pane));
// 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
@@ -463,11 +553,15 @@ App.feed = App.feed || {};
slideVideo.loop = shouldLoop();
slideVideo.addEventListener('ended', () => {
if (shouldLoop()) return;
if (!slide.classList.contains('is-active')) return;
advanceToNext(slide._index);
// Only the active step advances, and only from its first pane, so a
// short clip in one panel can't drag the others along with it.
const slide = pane.closest('.feed-slide');
if (!slide || !slide.classList.contains('is-active')) return;
if (slide._panes && slide._panes[0] !== pane) return;
advanceToNext(slide._step);
});
const favBtn = slide.querySelector('.feed-fav-btn');
const favBtn = pane.querySelector('.feed-fav-btn');
if (favBtn && App.favorites) {
App.favorites.setButtonState(favBtn, App.favorites.has(v));
favBtn.addEventListener('click', (event) => {
@@ -476,18 +570,110 @@ App.feed = App.feed || {};
});
}
// Insert before the rendered slide with the next-highest index so DOM
// order always matches index order; fall back to the sentinel.
// Each panel owns its own sound, so two can play at once if that is
// what the reader wants. A new panel inherits whatever the feed-wide
// control is set to, so a step built later doesn't disagree with the
// ones already on screen -- which does mean splitting while unmuted
// gives you two audio tracks.
const muteBtn = pane.querySelector('.feed-pane-mute');
const syncMute = () => {
slideVideo.muted = pane._muted;
muteBtn.textContent = pane._muted ? '🔇' : '🔊';
muteBtn.setAttribute('aria-label', pane._muted ? 'Unmute this panel' : 'Mute this panel');
muteBtn.classList.toggle('is-muted', !!pane._muted);
};
pane._muted = state.feedMuted !== false;
pane._syncMute = syncMute;
syncMute();
muteBtn.addEventListener('click', (event) => {
event.stopPropagation();
pane._muted = !pane._muted;
syncMute();
refreshFeedMuteState();
});
return pane;
};
// The feed-wide button reads as muted only while every panel is, so it
// can't claim silence over a panel someone unmuted by hand.
const refreshFeedMuteState = function() {
let anyAudible = false;
slidesByIndex.forEach((slide) => {
panesOf(slide).forEach((pane) => { if (!pane._muted) anyAudible = true; });
});
state.feedMuted = !anyAudible;
App.feed.updateMuteButton();
};
// Renders the layout tree into elements, handing each leaf the next video.
const renderPaneTree = function(node, videos, cursor) {
if (node.type === 'pane') {
const v = videos[cursor.next++];
if (!v) return null;
const pane = buildPane(v, cursor.next - 1);
pane._node = node;
bindPaneTools(pane, node);
return pane;
}
const split = document.createElement('div');
split.className = 'feed-split ' + (node.dir === 'row' ? 'is-row' : 'is-col');
node.children.forEach((child) => {
const el = renderPaneTree(child, videos, cursor);
if (el) split.appendChild(el);
});
return split.childElementCount ? split : null;
};
const bindPaneTools = function(pane, node) {
const closeBtn = pane.querySelector('.feed-pane-close');
closeBtn.hidden = paneCount() <= 1;
pane.querySelector('.feed-pane-split-right').addEventListener('click', (event) => {
event.stopPropagation();
splitPane(node, 'row');
});
pane.querySelector('.feed-pane-split-down').addEventListener('click', (event) => {
event.stopPropagation();
splitPane(node, 'col');
});
closeBtn.addEventListener('click', (event) => {
event.stopPropagation();
closePane(node);
});
};
// One screenful: the pane tree, filled with this step's run of videos.
const createSlide = function(step) {
if (slidesByIndex.has(step)) return slidesByIndex.get(step);
const videos = state.loadedVideos || [];
const per = paneCount();
const first = step * per;
if (!videos[first]) return null;
const scroller = getScroller();
if (!scroller) return null;
const slide = document.createElement('div');
slide.className = 'feed-slide';
slide.dataset.step = String(step);
slide._step = step;
const cursor = { next: first };
const tree = renderPaneTree(paneTree, videos, cursor);
if (!tree) return null;
slide.appendChild(tree);
slide._panes = Array.from(slide.querySelectorAll('.feed-pane'));
// Insert before the rendered slide with the next-highest step so DOM
// order always matches step order; fall back to the sentinel.
let ref = getSentinel();
let refIndex = Infinity;
let refStep = Infinity;
slidesByIndex.forEach((el, i) => {
if (i > index && i < refIndex) {
refIndex = i;
if (i > step && i < refStep) {
refStep = i;
ref = el;
}
});
scroller.insertBefore(slide, ref);
slidesByIndex.set(index, slide);
slidesByIndex.set(step, slide);
return slide;
};
@@ -496,14 +682,24 @@ App.feed = App.feed || {};
// 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;
// A slide is now a container: everything below that used to act on one
// video acts on one pane, and the slide fans out over its panes.
const panesOf = function(slide) {
return (slide && slide._panes) || [];
};
const teardownPane = function(pane) {
destroySlidePlayback(pane);
if (Array.isArray(pane._sharedControlCleanups)) {
pane._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
pane._sharedControlCleanups = null;
}
};
const teardownSlide = function(slide) {
panesOf(slide).forEach(teardownPane);
};
const removeSlide = function(index) {
const slide = slidesByIndex.get(index);
if (!slide) return;
@@ -512,37 +708,24 @@ App.feed = App.feed || {};
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).
// next clip into its place.
//
// Removing a video renumbers everything after it, and with panes a step is
// a run of videos rather than one -- so the steps from the hole onwards
// have to be rebuilt. The steps *before* it keep their videos, which is
// what lets a failed preload neighbour leave the active panes playing
// untouched; only a failure at or before what's on screen forces the
// active step to be rebuilt with it.
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);
// Which video to stay with afterwards: the one that slides into the
// failed clip's place, or the last one if it was at the end.
const keep = videos[r + 1] || videos[r - 1] || null;
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.
@@ -555,21 +738,22 @@ App.feed = App.feed || {};
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();
const per = paneCount();
const activeLast = (state.feedActiveIndex + 1) * per - 1;
if (state.feedActiveIndex >= 0 && r > activeLast) {
// The hole is past everything on screen: drop the steps from it
// onwards and let the window rebuild them, leaving the active panes
// mid-playback exactly as they are.
const fromStep = Math.floor(r / per);
const stale = [];
slidesByIndex.forEach((slide, i) => { if (i >= fromStep) stale.push(i); });
stale.forEach(removeSlide);
syncWindow(state.feedActiveIndex);
return;
}
state.feedActiveVideoId = keep ? keep.id : null;
rebuildLayout();
};
// Flags a slide whose video failed and schedules its removal from the queue.
@@ -577,13 +761,13 @@ App.feed = App.feed || {};
// 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');
const markSlideFailed = function(pane) {
if (!pane || pane._failed || !state.feedOpen) return;
const video = pane.querySelector('.feed-video');
if (video && video._tearingDown) return;
const id = slideVideoId(slide);
const id = slideVideoId(pane);
if (id == null) return;
slide._failed = true;
pane._failed = true;
setTimeout(() => removeVideoFromQueue(id), 0);
};
@@ -591,14 +775,40 @@ App.feed = App.feed || {};
// 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.
// Which steps are materialised. Steps ahead/behind are counted in steps,
// but each one now costs a whole step's worth of <video> elements, so
// splitting four ways should not put four times as many in the document:
// the window narrows as panes are added.
//
// The top spacer stands in for the steps below `start`, so anything that
// sizes the spacer has to agree with this exactly -- disagreeing by a step
// shifts every rendered slide and lands the reader on the wrong videos.
const windowBounds = function(activeIndex) {
const per = paneCount();
return {
start: Math.max(0, activeIndex - Math.max(1, Math.round(HISTORY_COUNT / per))),
end: Math.min(stepCount() - 1,
activeIndex + Math.max(1, Math.round(RENDER_AHEAD / per)))
};
};
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);
if (stepCount() === 0) return;
const { start, end } = windowBounds(activeIndex);
const per = paneCount();
const videos = state.loadedVideos || [];
slidesByIndex.forEach((slide, i) => {
if (i < start || i > end) removeSlide(i);
if (i < start || i > end) {
removeSlide(i);
return;
}
// A step built before all of its videos had arrived is short a pane
// or more. Once the rest land it has to be rebuilt, or the videos
// that would have filled it are skipped for good: the next step
// starts past them.
const have = (slide._panes || []).length;
if (have < per && videos[i * per + have]) removeSlide(i);
});
for (let i = start; i <= end; i++) {
if (!slidesByIndex.has(i)) createSlide(i);
@@ -612,7 +822,7 @@ App.feed = App.feed || {};
// 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;
const bufferAhead = total - 1 - ((activeIndex + 1) * paneCount() - 1);
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
&& state.hasNextPage && !state.isLoading) {
// The feed is its own reader: the grid's scroll position says
@@ -628,7 +838,7 @@ App.feed = App.feed || {};
const clamped = clampIndex(index);
if (clamped < 0) return;
state.feedActiveIndex = clamped;
const activeVideo = (state.loadedVideos || [])[clamped];
const activeVideo = (state.loadedVideos || [])[clamped * paneCount()];
state.feedActiveVideoId = activeVideo ? activeVideo.id : null;
syncWindow(clamped);
@@ -639,21 +849,26 @@ App.feed = App.feed || {};
const activeSlide = slidesByIndex.get(clamped);
if (activeSlide) {
loadSlideSource(activeSlide, activeSlide._videoData, true);
requestAnimationFrame(() => measureFeedTitle(activeSlide));
panesOf(activeSlide).forEach((pane) => {
loadSlideSource(pane, pane._videoData, true);
requestAnimationFrame(() => measureFeedTitle(pane));
});
}
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);
}
const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
panesOf(slide).forEach((pane) => {
if (i > clamped && i <= clamped + preloadAhead) {
loadSlideSource(pane, pane._videoData, false);
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
// Recently-watched panes stay loaded but paused so scrolling
// back resumes seamlessly from where it was paused.
if (pane.classList.contains('is-loaded')) pauseSlide(pane);
} else if (pane.classList.contains('is-loaded')) {
destroySlidePlayback(pane);
}
});
});
prefetchIfNeeded(clamped);
@@ -682,19 +897,18 @@ App.feed = App.feed || {};
// 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;
if (stepCount() === 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;
if (found >= 0) index = Math.floor(found / paneCount());
}
index = clampIndex(index);
if (index < 0) return;
state.feedActiveIndex = index;
const h = slideHeight();
const start = Math.max(0, index - HISTORY_COUNT);
const start = windowBounds(index).start;
const spacer = getTopSpacer();
if (spacer) spacer.style.height = `${start * h}px`;
const scroller = getScroller();
@@ -731,8 +945,10 @@ App.feed = App.feed || {};
App.feed.applyEndBehavior = function() {
const loop = shouldLoop();
slidesByIndex.forEach((slide) => {
const video = slide.querySelector('.feed-video');
if (video) video.loop = loop;
panesOf(slide).forEach((pane) => {
const video = pane.querySelector('.feed-video');
if (video) video.loop = loop;
});
});
};
@@ -802,7 +1018,7 @@ App.feed = App.feed || {};
if (startVideoId != null) {
const found = (state.loadedVideos || [])
.findIndex((v) => String(v.id) === String(startVideoId));
if (found >= 0) startIndex = found;
if (found >= 0) startIndex = Math.floor(found / paneCount());
}
// Force a fresh activation even if the index happens to match.
@@ -824,7 +1040,7 @@ App.feed = App.feed || {};
hudIdleTimer = null;
}
document.body.classList.remove('feed-hud-idle');
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
slidesByIndex.forEach((slide) => panesOf(slide).forEach(destroySlidePlayback));
container.classList.remove('open');
container.setAttribute('aria-hidden', 'true');
document.body.classList.remove('feed-mode-open');
@@ -843,10 +1059,15 @@ App.feed = App.feed || {};
}
};
// The feed-wide control now sets every panel at once; each panel still has
// its own switch for when they should differ.
App.feed.toggleMute = function() {
state.feedMuted = !state.feedMuted;
document.querySelectorAll('.feed-video').forEach((video) => {
video.muted = state.feedMuted;
slidesByIndex.forEach((slide) => {
panesOf(slide).forEach((pane) => {
pane._muted = state.feedMuted;
if (pane._syncMute) pane._syncMute();
});
});
App.feed.updateMuteButton();
};