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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user