diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 3b171a6..9b99ed7 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -32,6 +32,16 @@ App.feed = App.feed || {}; let scrollBound = false; let scrollRaf = null; + // The slide height under which the current scroll position / top-spacer were + // last laid out. On an orientation change the viewport (and therefore the + // slide height) changes, which makes the browser re-snap the scroll-snap + // container and fire scroll events with positions that no longer map to the + // active slide. We compare against this so onScroll can ignore those + // resize-induced scroll events and let onResize realign to the active video + // instead -- the active slide must never change just because the device was + // rotated. It's updated only when we (re)align the scroll position ourselves. + let lastSlideHeight = 0; + // HUD auto-hide: the reels HUD fades out after this much inactivity and // reappears on any pointer movement / tap / scroll. Buttons keep their // pointer-events while hidden, so they stay clickable even when invisible. @@ -385,6 +395,8 @@ App.feed = App.feed || {}; const clamped = clampIndex(index); if (clamped < 0) return; state.feedActiveIndex = clamped; + const activeVideo = (state.loadedVideos || [])[clamped]; + state.feedActiveVideoId = activeVideo ? activeVideo.id : null; syncWindow(clamped); @@ -421,7 +433,14 @@ App.feed = App.feed || {}; scrollRaf = null; const scroller = getScroller(); if (!scroller) return; - const index = clampIndex(Math.round(scroller.scrollTop / slideHeight())); + const h = slideHeight(); + // A scroll that fires while the slide height differs from what the + // current layout was aligned to is a side effect of a viewport + // change (e.g. an orientation switch re-snapping the container), not + // a real user swipe. Ignore it and let onResize re-anchor to the + // active video, so rotating the device never jumps to another slide. + if (h !== lastSlideHeight) return; + const index = clampIndex(Math.round(scroller.scrollTop / h)); if (index < 0) return; if (index !== state.feedActiveIndex) { setActive(index); @@ -429,18 +448,42 @@ App.feed = App.feed || {}; }); }; - const onResize = function() { - if (!state.feedOpen || state.feedActiveIndex < 0) return; + // Re-anchors the scroll position on the currently active video after the + // viewport changes. The active slide is resolved by id (not by a possibly + // 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; + 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; + } + index = clampIndex(index); + if (index < 0) return; + state.feedActiveIndex = index; const h = slideHeight(); - const start = Math.max(0, state.feedActiveIndex - HISTORY_COUNT); + const start = Math.max(0, index - HISTORY_COUNT); const spacer = getTopSpacer(); if (spacer) spacer.style.height = `${start * h}px`; const scroller = getScroller(); - if (scroller) scroller.scrollTop = state.feedActiveIndex * h; - const activeSlide = slidesByIndex.get(state.feedActiveIndex); + if (scroller) scroller.scrollTop = index * h; + lastSlideHeight = h; + const activeSlide = slidesByIndex.get(index); if (activeSlide) measureFeedTitle(activeSlide); }; + const onResize = function() { + if (!state.feedOpen || state.feedActiveIndex < 0) return; + realignToActive(); + // Orientation changes can settle over more than one frame (the visual + // viewport and the scroll-snap re-anchor in stages); realign again once + // layout has settled so the active video stays put either way. + requestAnimationFrame(realignToActive); + }; + App.feed.isOpen = function() { return !!state.feedOpen; }; @@ -461,6 +504,8 @@ App.feed = App.feed || {}; slidesByIndex.clear(); resumeTimes.clear(); state.feedActiveIndex = -1; + state.feedActiveVideoId = null; + lastSlideHeight = 0; const spacer = getTopSpacer(); if (spacer) spacer.style.height = '0px'; const scroller = getScroller(); @@ -506,7 +551,8 @@ App.feed = App.feed || {}; // Force a fresh activation even if the index happens to match. state.feedActiveIndex = -1; setActive(startIndex); - scroller.scrollTop = startIndex * slideHeight(); + lastSlideHeight = slideHeight(); + scroller.scrollTop = startIndex * lastSlideHeight; App.feed.updateToggleButton(); App.feed.updateMuteButton(); diff --git a/frontend/js/state.js b/frontend/js/state.js index 44d3dd7..b50f7b7 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -18,6 +18,7 @@ App.state = { feedOpen: false, feedMuted: true, feedActiveIndex: -1, + feedActiveVideoId: null, groupCursors: null }; diff --git a/frontend/js/videos.js b/frontend/js/videos.js index c428d51..592a480 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -749,15 +749,58 @@ App.videos = App.videos || {}; update(); }; + // Records the topmost card crossing (or just below) the viewport top, + // plus how far its top sits from the viewport top. A column re-pack (on + // resize / orientation change) reassigns every card's position, so the + // raw scrollTop would otherwise point at a different video afterwards. + // Anchoring on the topmost visible card (rather than the centred one) + // is independent of the viewport height, which has *already* changed by + // the time a resize/orientation event fires -- so it stays correct even + // as portrait<->landscape swaps the height out from under us. + const captureAnchor = function() { + let anchor = null; + let bestTop = Infinity; + mounted.forEach((card, i) => { + const rect = card.getBoundingClientRect(); + if (rect.bottom <= 0) return; // fully scrolled past + if (rect.top < bestTop) { + bestTop = rect.top; + anchor = { index: i, offsetTop: rect.top }; + } + }); + return anchor; + }; + + // Scrolls so the anchored card sits at the same viewport offset it had + // before the re-pack, keeping the user's place across the layout change. + const restoreAnchor = function(anchor) { + if (!anchor) return; + const el = grid(); + const l = layout[anchor.index]; + if (!el || !l) return; + const gridTopDoc = el.getBoundingClientRect().top + window.scrollY; + const target = gridTopDoc + l.top - anchor.offsetTop; + window.scrollTo(0, Math.max(0, target)); + }; + let resizeRaf = null; + let pendingAnchor = null; const ensureInit = function() { if (!cols) measureMetrics(); if (initialized) return; initialized = true; window.addEventListener('scroll', scheduleUpdate, { passive: true }); window.addEventListener('resize', () => { + // Capture before the re-pack (positions are still the old ones) + // and keep the earliest anchor across a burst of resize events. + if (!pendingAnchor) pendingAnchor = captureAnchor(); if (resizeRaf) cancelAnimationFrame(resizeRaf); - resizeRaf = requestAnimationFrame(relayout); + resizeRaf = requestAnimationFrame(() => { + resizeRaf = null; + relayout(); + restoreAnchor(pendingAnchor); + pendingAnchor = null; + }); }); };