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:
@@ -1324,7 +1324,7 @@ body.theme-light .video-menu-btn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Live streams can't be seeked, so hide the feed scrubber. */
|
/* 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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2158,6 +2158,9 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
flex: none;
|
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 {
|
.feed-slide {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2165,6 +2168,34 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
scroll-snap-stop: always;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -2172,6 +2203,45 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
background: #000;
|
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-poster,
|
||||||
.feed-video {
|
.feed-video {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -2185,7 +2255,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-slide.is-loaded .feed-poster {
|
.feed-pane.is-loaded .feed-poster {
|
||||||
opacity: 0;
|
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. */
|
/* 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 {
|
.feed-fav-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: auto;
|
top: auto;
|
||||||
|
|||||||
@@ -74,8 +74,14 @@ App.feed = App.feed || {};
|
|||||||
return (scroller && scroller.clientHeight) || window.innerHeight || 1;
|
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;
|
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;
|
if (total === 0) return -1;
|
||||||
return Math.min(total - 1, Math.max(0, index));
|
return Math.min(total - 1, Math.max(0, index));
|
||||||
};
|
};
|
||||||
@@ -315,7 +321,7 @@ App.feed = App.feed || {};
|
|||||||
if (!video) return;
|
if (!video) return;
|
||||||
if (slide.classList.contains('is-loaded')) {
|
if (slide.classList.contains('is-loaded')) {
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
video.muted = state.feedMuted;
|
video.muted = slide._muted !== false;
|
||||||
const playPromise = video.play();
|
const playPromise = video.play();
|
||||||
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -352,7 +358,7 @@ App.feed = App.feed || {};
|
|||||||
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||||
const isHls = App.videos.classifySource(resolved).isHls;
|
const isHls = App.videos.classifySource(resolved).isHls;
|
||||||
|
|
||||||
video.muted = state.feedMuted;
|
video.muted = slide._muted !== false;
|
||||||
video.preload = 'auto';
|
video.preload = 'auto';
|
||||||
video._tearingDown = false;
|
video._tearingDown = false;
|
||||||
applyResume(video, videoData && videoData.id, resolved.isLive);
|
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
|
// 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
|
// inserts it into the DOM in index order, between the top spacer and the
|
||||||
// sentinel.
|
// sentinel.
|
||||||
const createSlide = function(index) {
|
// ------------------------------------------------------------------
|
||||||
if (slidesByIndex.has(index)) return slidesByIndex.get(index);
|
// Panes
|
||||||
const v = (state.loadedVideos || [])[index];
|
//
|
||||||
if (!v) return null;
|
// A step of the feed is one screenful, and it holds one or more panes. The
|
||||||
const scroller = getScroller();
|
// arrangement is a tree: every pane can be split to the right or below,
|
||||||
if (!scroller) return null;
|
// 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');
|
const paneLeaves = function(node, out) {
|
||||||
slide.className = v.isLive ? 'feed-slide is-live' : 'feed-slide';
|
out = out || [];
|
||||||
slide.dataset.videoId = v.id;
|
if (node.type === 'pane') out.push(node);
|
||||||
slide.dataset.index = String(index);
|
else node.children.forEach((child) => paneLeaves(child, out));
|
||||||
slide._videoData = v;
|
return out;
|
||||||
slide._index = index;
|
};
|
||||||
|
|
||||||
|
// 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 uploaderText = v.uploader || '';
|
||||||
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
||||||
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
||||||
slide.innerHTML = `
|
pane.innerHTML = `
|
||||||
<img class="feed-poster" alt="" loading="lazy" decoding="async">
|
<img class="feed-poster" alt="" loading="lazy" decoding="async">
|
||||||
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
|
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
|
||||||
${liveBadge}
|
${liveBadge}
|
||||||
@@ -433,6 +517,12 @@ App.feed = App.feed || {};
|
|||||||
</button>
|
</button>
|
||||||
<button class="cp-format-btn feed-format-btn" type="button" aria-label="Quality" hidden>HD</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="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="cp-flash feed-flash" aria-hidden="true"></div>
|
||||||
<div class="feed-info">
|
<div class="feed-info">
|
||||||
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>
|
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>
|
||||||
@@ -445,17 +535,17 @@ App.feed = App.feed || {};
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const poster = slide.querySelector('.feed-poster');
|
const poster = pane.querySelector('.feed-poster');
|
||||||
App.videos.attachThumbnail(poster, v.thumb);
|
App.videos.attachThumbnail(poster, v.thumb);
|
||||||
const slideVideo = slide.querySelector('.feed-video');
|
const slideVideo = pane.querySelector('.feed-video');
|
||||||
bindTimeline(slide, slideVideo);
|
bindTimeline(pane, slideVideo);
|
||||||
bindSharedControls(slide, slideVideo, v);
|
bindSharedControls(pane, slideVideo, v);
|
||||||
|
|
||||||
// A media error (bad/expired source, network failure, unsupported codec)
|
// A media error (bad/expired source, network failure, unsupported codec)
|
||||||
// means this clip can't play -- drop it from the queue. Errors fired by
|
// 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
|
// our own teardown (src cleared) carry the _tearingDown flag and are
|
||||||
// ignored inside markSlideFailed.
|
// 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`
|
// 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
|
// 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.loop = shouldLoop();
|
||||||
slideVideo.addEventListener('ended', () => {
|
slideVideo.addEventListener('ended', () => {
|
||||||
if (shouldLoop()) return;
|
if (shouldLoop()) return;
|
||||||
if (!slide.classList.contains('is-active')) return;
|
// Only the active step advances, and only from its first pane, so a
|
||||||
advanceToNext(slide._index);
|
// 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) {
|
if (favBtn && App.favorites) {
|
||||||
App.favorites.setButtonState(favBtn, App.favorites.has(v));
|
App.favorites.setButtonState(favBtn, App.favorites.has(v));
|
||||||
favBtn.addEventListener('click', (event) => {
|
favBtn.addEventListener('click', (event) => {
|
||||||
@@ -476,18 +570,110 @@ App.feed = App.feed || {};
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert before the rendered slide with the next-highest index so DOM
|
// Each panel owns its own sound, so two can play at once if that is
|
||||||
// order always matches index order; fall back to the sentinel.
|
// 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 ref = getSentinel();
|
||||||
let refIndex = Infinity;
|
let refStep = Infinity;
|
||||||
slidesByIndex.forEach((el, i) => {
|
slidesByIndex.forEach((el, i) => {
|
||||||
if (i > index && i < refIndex) {
|
if (i > step && i < refStep) {
|
||||||
refIndex = i;
|
refStep = i;
|
||||||
ref = el;
|
ref = el;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
scroller.insertBefore(slide, ref);
|
scroller.insertBefore(slide, ref);
|
||||||
slidesByIndex.set(index, slide);
|
slidesByIndex.set(step, slide);
|
||||||
return slide;
|
return slide;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -496,14 +682,24 @@ App.feed = App.feed || {};
|
|||||||
// does not remove it from the DOM or from slidesByIndex (callers differ
|
// does not remove it from the DOM or from slidesByIndex (callers differ
|
||||||
// on that: removeSlide always does, reset() removes the whole tree at
|
// on that: removeSlide always does, reset() removes the whole tree at
|
||||||
// once).
|
// once).
|
||||||
const teardownSlide = function(slide) {
|
// A slide is now a container: everything below that used to act on one
|
||||||
destroySlidePlayback(slide);
|
// video acts on one pane, and the slide fans out over its panes.
|
||||||
if (Array.isArray(slide._sharedControlCleanups)) {
|
const panesOf = function(slide) {
|
||||||
slide._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
return (slide && slide._panes) || [];
|
||||||
slide._sharedControlCleanups = null;
|
};
|
||||||
|
|
||||||
|
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 removeSlide = function(index) {
|
||||||
const slide = slidesByIndex.get(index);
|
const slide = slidesByIndex.get(index);
|
||||||
if (!slide) return;
|
if (!slide) return;
|
||||||
@@ -512,37 +708,24 @@ App.feed = App.feed || {};
|
|||||||
slidesByIndex.delete(index);
|
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
|
// 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
|
// next clip into its place.
|
||||||
// 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
|
// Removing a video renumbers everything after it, and with panes a step is
|
||||||
// the same scroll position, so playback advances without a visible jump).
|
// 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 removeVideoFromQueue = function(videoId) {
|
||||||
const videos = state.loadedVideos || [];
|
const videos = state.loadedVideos || [];
|
||||||
const r = videos.findIndex((v) => String(v.id) === String(videoId));
|
const r = videos.findIndex((v) => String(v.id) === String(videoId));
|
||||||
if (r < 0) return;
|
if (r < 0) return;
|
||||||
const prevActive = state.feedActiveIndex;
|
|
||||||
|
|
||||||
// Drop the failed clip's feed slide element from the DOM, then its JSON
|
// Which video to stay with afterwards: the one that slides into the
|
||||||
// from the queue, then re-key the remaining rendered slides.
|
// failed clip's place, or the last one if it was at the end.
|
||||||
removeSlide(r);
|
const keep = videos[r + 1] || videos[r - 1] || null;
|
||||||
videos.splice(r, 1);
|
videos.splice(r, 1);
|
||||||
reindexAfterRemoval(r);
|
|
||||||
|
|
||||||
// Remove the failed clip's grid card element from the DOM too (the grid
|
// Remove the failed clip's grid card element from the DOM too (the grid
|
||||||
// shares the queue) and re-pack the remaining cards.
|
// shares the queue) and re-pack the remaining cards.
|
||||||
@@ -555,21 +738,22 @@ App.feed = App.feed || {};
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The active slot only moves when the removed clip was the active one
|
const per = paneCount();
|
||||||
// (r === prevActive) or, defensively, sat before it.
|
const activeLast = (state.feedActiveIndex + 1) * per - 1;
|
||||||
let newActive = prevActive;
|
if (state.feedActiveIndex >= 0 && r > activeLast) {
|
||||||
if (r < prevActive) newActive -= 1;
|
// The hole is past everything on screen: drop the steps from it
|
||||||
newActive = clampIndex(newActive);
|
// onwards and let the window rebuild them, leaving the active panes
|
||||||
|
// mid-playback exactly as they are.
|
||||||
state.feedActiveIndex = -1; // force setActive to re-promote the slot
|
const fromStep = Math.floor(r / per);
|
||||||
setActive(newActive);
|
const stale = [];
|
||||||
|
slidesByIndex.forEach((slide, i) => { if (i >= fromStep) stale.push(i); });
|
||||||
if (r <= prevActive) {
|
stale.forEach(removeSlide);
|
||||||
// Active clip failed: re-anchor scroll onto the clip that slid into
|
syncWindow(state.feedActiveIndex);
|
||||||
// its slot so the snap container stays pinned to the new active.
|
return;
|
||||||
const scroller = getScroller();
|
|
||||||
if (scroller) scroller.scrollTop = newActive * slideHeight();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.feedActiveVideoId = keep ? keep.id : null;
|
||||||
|
rebuildLayout();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Flags a slide whose video failed and schedules its removal from the queue.
|
// 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)
|
// 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
|
// are ignored via the video's _tearingDown flag, and we only act while the
|
||||||
// feed is open so late errors after close are harmless.
|
// feed is open so late errors after close are harmless.
|
||||||
const markSlideFailed = function(slide) {
|
const markSlideFailed = function(pane) {
|
||||||
if (!slide || slide._failed || !state.feedOpen) return;
|
if (!pane || pane._failed || !state.feedOpen) return;
|
||||||
const video = slide.querySelector('.feed-video');
|
const video = pane.querySelector('.feed-video');
|
||||||
if (video && video._tearingDown) return;
|
if (video && video._tearingDown) return;
|
||||||
const id = slideVideoId(slide);
|
const id = slideVideoId(pane);
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
slide._failed = true;
|
pane._failed = true;
|
||||||
setTimeout(() => removeVideoFromQueue(id), 0);
|
setTimeout(() => removeVideoFromQueue(id), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -591,14 +775,40 @@ App.feed = App.feed || {};
|
|||||||
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
// 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
|
// any missing ones inside it, and sizes the top spacer to stand in for the
|
||||||
// slides above the window.
|
// 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 syncWindow = function(activeIndex) {
|
||||||
const total = (state.loadedVideos || []).length;
|
if (stepCount() === 0) return;
|
||||||
if (total === 0) return;
|
const { start, end } = windowBounds(activeIndex);
|
||||||
const start = Math.max(0, activeIndex - HISTORY_COUNT);
|
const per = paneCount();
|
||||||
const end = Math.min(total - 1, activeIndex + RENDER_AHEAD);
|
const videos = state.loadedVideos || [];
|
||||||
|
|
||||||
slidesByIndex.forEach((slide, i) => {
|
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++) {
|
for (let i = start; i <= end; i++) {
|
||||||
if (!slidesByIndex.has(i)) createSlide(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.
|
// JSON, pull the next page so the buffer stays ahead of the rendered window.
|
||||||
const prefetchIfNeeded = function(activeIndex) {
|
const prefetchIfNeeded = function(activeIndex) {
|
||||||
const total = (state.loadedVideos || []).length;
|
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)
|
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
|
||||||
&& state.hasNextPage && !state.isLoading) {
|
&& state.hasNextPage && !state.isLoading) {
|
||||||
// The feed is its own reader: the grid's scroll position says
|
// The feed is its own reader: the grid's scroll position says
|
||||||
@@ -628,7 +838,7 @@ App.feed = App.feed || {};
|
|||||||
const clamped = clampIndex(index);
|
const clamped = clampIndex(index);
|
||||||
if (clamped < 0) return;
|
if (clamped < 0) return;
|
||||||
state.feedActiveIndex = clamped;
|
state.feedActiveIndex = clamped;
|
||||||
const activeVideo = (state.loadedVideos || [])[clamped];
|
const activeVideo = (state.loadedVideos || [])[clamped * paneCount()];
|
||||||
state.feedActiveVideoId = activeVideo ? activeVideo.id : null;
|
state.feedActiveVideoId = activeVideo ? activeVideo.id : null;
|
||||||
|
|
||||||
syncWindow(clamped);
|
syncWindow(clamped);
|
||||||
@@ -639,21 +849,26 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
const activeSlide = slidesByIndex.get(clamped);
|
const activeSlide = slidesByIndex.get(clamped);
|
||||||
if (activeSlide) {
|
if (activeSlide) {
|
||||||
loadSlideSource(activeSlide, activeSlide._videoData, true);
|
panesOf(activeSlide).forEach((pane) => {
|
||||||
requestAnimationFrame(() => measureFeedTitle(activeSlide));
|
loadSlideSource(pane, pane._videoData, true);
|
||||||
|
requestAnimationFrame(() => measureFeedTitle(pane));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
slidesByIndex.forEach((slide, i) => {
|
slidesByIndex.forEach((slide, i) => {
|
||||||
if (i === clamped) return;
|
if (i === clamped) return;
|
||||||
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
const preloadAhead = Math.max(1, Math.round(PRELOAD_COUNT / paneCount()));
|
||||||
loadSlideSource(slide, slide._videoData, false);
|
panesOf(slide).forEach((pane) => {
|
||||||
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
if (i > clamped && i <= clamped + preloadAhead) {
|
||||||
// Recently-watched slides stay loaded but paused so scrolling
|
loadSlideSource(pane, pane._videoData, false);
|
||||||
// back resumes seamlessly from where it was paused.
|
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
||||||
if (slide.classList.contains('is-loaded')) pauseSlide(slide);
|
// Recently-watched panes stay loaded but paused so scrolling
|
||||||
} else if (slide.classList.contains('is-loaded')) {
|
// back resumes seamlessly from where it was paused.
|
||||||
destroySlidePlayback(slide);
|
if (pane.classList.contains('is-loaded')) pauseSlide(pane);
|
||||||
}
|
} else if (pane.classList.contains('is-loaded')) {
|
||||||
|
destroySlidePlayback(pane);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
prefetchIfNeeded(clamped);
|
prefetchIfNeeded(clamped);
|
||||||
@@ -682,19 +897,18 @@ App.feed = App.feed || {};
|
|||||||
// stale scroll position) so an orientation change always keeps the same
|
// stale scroll position) so an orientation change always keeps the same
|
||||||
// video playing/focused rather than snapping to a neighbour.
|
// video playing/focused rather than snapping to a neighbour.
|
||||||
const realignToActive = function() {
|
const realignToActive = function() {
|
||||||
const total = (state.loadedVideos || []).length;
|
if (stepCount() === 0) return;
|
||||||
if (total === 0) return;
|
|
||||||
let index = state.feedActiveIndex;
|
let index = state.feedActiveIndex;
|
||||||
if (state.feedActiveVideoId != null) {
|
if (state.feedActiveVideoId != null) {
|
||||||
const found = (state.loadedVideos || [])
|
const found = (state.loadedVideos || [])
|
||||||
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
|
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
|
||||||
if (found >= 0) index = found;
|
if (found >= 0) index = Math.floor(found / paneCount());
|
||||||
}
|
}
|
||||||
index = clampIndex(index);
|
index = clampIndex(index);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
state.feedActiveIndex = index;
|
state.feedActiveIndex = index;
|
||||||
const h = slideHeight();
|
const h = slideHeight();
|
||||||
const start = Math.max(0, index - HISTORY_COUNT);
|
const start = windowBounds(index).start;
|
||||||
const spacer = getTopSpacer();
|
const spacer = getTopSpacer();
|
||||||
if (spacer) spacer.style.height = `${start * h}px`;
|
if (spacer) spacer.style.height = `${start * h}px`;
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
@@ -731,8 +945,10 @@ App.feed = App.feed || {};
|
|||||||
App.feed.applyEndBehavior = function() {
|
App.feed.applyEndBehavior = function() {
|
||||||
const loop = shouldLoop();
|
const loop = shouldLoop();
|
||||||
slidesByIndex.forEach((slide) => {
|
slidesByIndex.forEach((slide) => {
|
||||||
const video = slide.querySelector('.feed-video');
|
panesOf(slide).forEach((pane) => {
|
||||||
if (video) video.loop = loop;
|
const video = pane.querySelector('.feed-video');
|
||||||
|
if (video) video.loop = loop;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -802,7 +1018,7 @@ App.feed = App.feed || {};
|
|||||||
if (startVideoId != null) {
|
if (startVideoId != null) {
|
||||||
const found = (state.loadedVideos || [])
|
const found = (state.loadedVideos || [])
|
||||||
.findIndex((v) => String(v.id) === String(startVideoId));
|
.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.
|
// Force a fresh activation even if the index happens to match.
|
||||||
@@ -824,7 +1040,7 @@ App.feed = App.feed || {};
|
|||||||
hudIdleTimer = null;
|
hudIdleTimer = null;
|
||||||
}
|
}
|
||||||
document.body.classList.remove('feed-hud-idle');
|
document.body.classList.remove('feed-hud-idle');
|
||||||
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
slidesByIndex.forEach((slide) => panesOf(slide).forEach(destroySlidePlayback));
|
||||||
container.classList.remove('open');
|
container.classList.remove('open');
|
||||||
container.setAttribute('aria-hidden', 'true');
|
container.setAttribute('aria-hidden', 'true');
|
||||||
document.body.classList.remove('feed-mode-open');
|
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() {
|
App.feed.toggleMute = function() {
|
||||||
state.feedMuted = !state.feedMuted;
|
state.feedMuted = !state.feedMuted;
|
||||||
document.querySelectorAll('.feed-video').forEach((video) => {
|
slidesByIndex.forEach((slide) => {
|
||||||
video.muted = state.feedMuted;
|
panesOf(slide).forEach((pane) => {
|
||||||
|
pane._muted = state.feedMuted;
|
||||||
|
if (pane._syncMute) pane._syncMute();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
App.feed.updateMuteButton();
|
App.feed.updateMuteButton();
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user