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