Stay on the same video when the phone turns

Rotating mid-reel jumped the reel forward a few videos. Two reasons, both
about the same arithmetic: every scroll position in the feed is index *
scroller.clientHeight, with a spacer standing in for the slides above.

A slide was 100dvh, which is not the scroller's height on a phone whose
chrome comes and goes, so that mapping addressed the wrong slide by a
growing margin; it is now the scroller's own height, which it cannot
disagree with.

And the realign window lasted two frames. A rotation is many frames and
several resizes, with the container's re-anchor on pre-rotation pixels
landing in the middle of them -- so the window now stays open until the
slide height has held still for a stretch of frames (with a clock to close
it in a tab that isn't drawing), and orientationchange and the visual
viewport open it too. Moving the feed into a picture-in-picture window
settles the same way.

tests/smoke_rotate.py replays a rotation as the browser delivers it, on a
fixture reel so nothing waits on a real source; against the old code the
reel goes from step 7 to step 12.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-10-18 19:40:37 +00:00
parent bc7c0047b7
commit b9f6a78fdc
4 changed files with 305 additions and 27 deletions

View File

@@ -2246,12 +2246,17 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
/* 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. */
the whole set.
The height is the scroller's own, not 100dvh: every scroll position in the
feed is computed as `index * scroller.clientHeight` (the top spacer stands
in for the slides above), so a slide that is even a few pixels off that
height makes the mapping drift by a slide's worth after a handful of them --
which is what used to make rotating the phone land on a different video. */
.feed-slide {
position: relative;
width: 100%;
height: 100vh;
height: 100dvh;
height: 100%;
scroll-snap-align: start;
scroll-snap-stop: always;
overflow: hidden;

View File

@@ -47,6 +47,19 @@ App.feed = App.feed || {};
// slide is playing. Normal swipes (no resize in flight) are unaffected.
let suppressScroll = false;
let resizeSettleRaf = null;
let resizeSettleTimer = null;
// A rotation is not one event. The viewport, the browser's own chrome and
// the scroll-snap container settle over many frames and often several
// `resize` events, so the realign window is held open until the slide
// height has held still for a stretch of frames -- not for a fixed two,
// which ran out while the viewport was still moving and let the stale
// scroll positions through, skipping the reel forward a few videos.
const SETTLE_STABLE_FRAMES = 10;
const SETTLE_MAX_MS = 1500;
let settleStartedAt = 0;
let settleStableFrames = 0;
let settleHeight = -1;
let settleDone = null; // run once the window closes, if anything asked
// HUD auto-hide: the reels HUD fades out after this much inactivity and
// reappears on any pointer movement / tap / scroll. Buttons keep their
@@ -1313,23 +1326,63 @@ App.feed = App.feed || {};
if (activeSlide) measureFeedTitle(activeSlide);
};
// Ends the realign window: one last realign, then real swipes count again.
const finishSettle = function() {
if (resizeSettleRaf) {
cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = null;
}
if (resizeSettleTimer) {
clearTimeout(resizeSettleTimer);
resizeSettleTimer = null;
}
realignToActive();
suppressScroll = false;
const after = settleDone;
settleDone = null;
if (after) after();
};
// Watches the viewport settle, frame by frame, holding the active video in
// place the whole way.
const settleStep = function() {
resizeSettleRaf = null;
if (!state.feedOpen || state.feedActiveIndex < 0) {
suppressScroll = false;
return;
}
const height = slideHeight();
if (height === settleHeight) settleStableFrames += 1;
else {
settleHeight = height;
settleStableFrames = 0;
}
realignToActive();
if (settleStableFrames < SETTLE_STABLE_FRAMES &&
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
resizeSettleRaf = requestAnimationFrame(settleStep);
return;
}
// One more frame, so a re-snap the container has already scheduled
// lands inside the window rather than just after it.
resizeSettleRaf = requestAnimationFrame(finishSettle);
};
const onResize = function() {
if (!state.feedOpen || state.feedActiveIndex < 0) return;
// Suppress scroll handling while we realign so the container's re-snap
// doesn't flip the active video, then re-enable it once layout settles.
suppressScroll = true;
settleStartedAt = Date.now();
settleStableFrames = 0;
settleHeight = -1;
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, then stop suppressing real swipes.
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = requestAnimationFrame(() => {
realignToActive();
resizeSettleRaf = requestAnimationFrame(() => {
resizeSettleRaf = null;
suppressScroll = false;
});
});
resizeSettleRaf = requestAnimationFrame(settleStep);
// Frames stop in a hidden tab, which would leave the window open for
// as long as the tab stays away; the clock closes it regardless.
if (resizeSettleTimer) clearTimeout(resizeSettleTimer);
resizeSettleTimer = setTimeout(finishSettle, SETTLE_MAX_MS + 500);
};
// ------------------------------------------------------------------
@@ -1559,21 +1612,21 @@ App.feed = App.feed || {};
// recomputes them from the active video's id -- the same thing an
// orientation change does.
const settleAfterMove = function() {
suppressScroll = true;
realignToActive();
requestAnimationFrame(() => {
realignToActive();
requestAnimationFrame(() => {
suppressScroll = false;
const slide = slidesByIndex.get(state.feedActiveIndex);
if (slide) panesOf(slide).forEach((pane) => {
const video = pane.querySelector('.feed-video');
if (video && video.paused && pane.classList.contains('is-loaded')) {
video.play().catch(() => {});
}
});
const resume = function() {
const slide = slidesByIndex.get(state.feedActiveIndex);
if (slide) panesOf(slide).forEach((pane) => {
const video = pane.querySelector('.feed-video');
if (video && video.paused && pane.classList.contains('is-loaded')) {
video.play().catch(() => {});
}
});
});
};
if (!state.feedOpen || state.feedActiveIndex < 0) {
resume();
return;
}
settleDone = resume;
onResize();
};
// Puts the feed back in the page. Called when the window closes, however it
@@ -1720,6 +1773,10 @@ App.feed = App.feed || {};
cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = null;
}
if (resizeSettleTimer) {
clearTimeout(resizeSettleTimer);
resizeSettleTimer = null;
}
const spacer = getTopSpacer();
if (spacer) spacer.style.height = '0px';
const scroller = getScroller();
@@ -1749,6 +1806,14 @@ App.feed = App.feed || {};
if (!scrollBound) {
scroller.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onResize);
// `orientationchange` fires before the new metrics are readable and
// the visual viewport moves on its own as the browser's chrome
// slides away, so both open the realign window too -- the watcher
// below waits for the height to stop moving either way.
window.addEventListener('orientationchange', onResize);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onResize);
}
scrollBound = true;
}