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:
@@ -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();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user