diff --git a/frontend/css/style.css b/frontend/css/style.css
index d86937c..7ee3091 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -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;
diff --git a/frontend/js/feed.js b/frontend/js/feed.js
index 799a729..eaf89f9 100644
--- a/frontend/js/feed.js
+++ b/frontend/js/feed.js
@@ -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 ? '● LIVE' : '';
const favKey = App.favorites ? App.favorites.getKey(v) : null;
- slide.innerHTML = `
+ pane.innerHTML = `
${liveBadge}
@@ -433,6 +517,12 @@ App.feed = App.feed || {};
+
+
+
+
+
+
${v.title || ''}
@@ -445,17 +535,17 @@ App.feed = App.feed || {};
`;
- 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