Turn the phone in one step, not through every shape

A turn is not one shape change: the viewport passes through every shape
between the one it left and the one it lands in, and each of them
re-letterboxes the picture somewhere else. Painted, that run of hard jumps
is the stutter.

So for the length of a turn the slides are held at the size they already
had, and the moment the viewport stops moving they take the size it ended
at -- measured, not guessed. The picture moves exactly once, straight into
the position and size it then keeps. The device's own `orientationchange`
starts the hold, which is the earliest anything can know a turn is coming
and the one signal that separates it from a window being dragged wider;
failing that, the viewport swapping its long side for its short one does. A
viewport that only changes height is the browser's chrome sliding away, and
is never held.

Underneath: snapping is off while the slides are held, so the container
cannot re-anchor on the old height at the same time; a scroll arriving
during the window is put back in the event rather than a frame later, so
the stale position is never painted; and the per-frame realign only writes
what actually differs instead of costing a layout sixty times a second.

Measured over a recorded turn: the picture moves 12 times -> 2, once per
turn; the worst of those was a 660px jump between two intermediate shapes
and is now the one step into the final size; the reel is never off its step
(was 660px out); and the clip never pauses.

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-19 13:40:30 +00:00
parent b9f6a78fdc
commit 6d9cc301a7
3 changed files with 403 additions and 7 deletions

View File

@@ -60,6 +60,35 @@ App.feed = App.feed || {};
let settleStableFrames = 0;
let settleHeight = -1;
let settleDone = null; // run once the window closes, if anything asked
let lastMeasuredHeight = -1;
// A turn is not just another resize. The viewport passes through every
// shape between the one it left and the one it lands in, and each one
// re-letterboxes the picture somewhere else: that run of hard jumps is
// what reads as a stutter. So for the length of the turn the slides are
// held at the size they already had, and the moment the viewport stops
// moving they take the size it ended at -- measured, not guessed, so the
// picture moves exactly once, straight into the place it then keeps.
// Only a real turn gets that: the browser's own chrome sliding away moves
// the viewport too, and holding for those would be worse than the jump it
// saves.
//
// `orientationchange` fires before the new metrics are readable, which is
// the earliest anything can know a turn is coming -- early enough to pin
// before the first new shape is painted. It is also the one signal that
// separates a turn from a window being dragged wider on a desktop.
const TURN_SIGNAL_MS = 1200;
// The hold comes off once the viewport has stopped moving -- sooner than
// the realign window closes, which goes on holding the reel in place for
// a while after that in case a last stale scroll arrives. Long enough
// that a viewport animating round in steps is not mistaken for one that
// has arrived, short enough not to be a wait.
const TURN_HOLD_FRAMES = 6;
let turnSignalAt = 0;
let turnPin = null; // the shape the slides are held at, mid-turn
let turnResolved = false;
let settleFromShape = null; // the shape the viewport had before this turn
let lastShape = null;
// HUD auto-hide: the reels HUD fades out after this much inactivity and
// reappears on any pointer movement / tap / scroll. Buttons keep their
@@ -137,6 +166,10 @@ App.feed = App.feed || {};
const getTopSpacer = () => inRoot('feed-top-spacer');
const slideHeight = function() {
// Mid-turn the slides are the size the turn is heading for, not the
// size the viewport happens to be passing through, and every scroll
// position in the feed is a multiple of a slide.
if (turnPin) return turnPin.h;
const scroller = getScroller();
const view = feedDocument().defaultView || window;
return (scroller && scroller.clientHeight) || view.innerHeight || 1;
@@ -1281,6 +1314,14 @@ App.feed = App.feed || {};
};
const onScroll = function() {
// A turn's re-anchor arrives as a scroll event carrying a position
// measured against the height from before it. Putting it back here,
// in the event, is what keeps that position from being painted --
// waiting for the next frame means showing it first.
if (suppressScroll) {
realignToActive();
return;
}
if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
@@ -1301,6 +1342,48 @@ App.feed = App.feed || {};
});
};
const viewportShape = function() {
const scroller = getScroller();
const view = feedDocument().defaultView || window;
return {
w: (scroller && scroller.clientWidth) || view.innerWidth || 1,
h: (scroller && scroller.clientHeight) || view.innerHeight || 1
};
};
// What a wrist did, as against what the browser's chrome did: the device
// said so, or the viewport swapped its long side for its short one. A
// viewport that only changes height -- chrome sliding away, a keyboard
// coming up -- is not a turn and is not worth a fade.
const isTurn = function(before, now) {
if (Date.now() - turnSignalAt < TURN_SIGNAL_MS) return true;
if (!before) return false;
return (before.w > before.h) !== (now.w > now.h);
};
// Holds the slides at `shape` -- the size they have as the turn starts --
// until the viewport has finished moving. Where a turn arrives in one
// step, which is most of them, this lasts a few frames and changes
// nothing anybody sees.
const pinTurn = function(shape) {
turnPin = { w: shape.w, h: shape.h };
const root = getRoot();
if (!root) return;
root.style.setProperty('--turn-w', `${turnPin.w}px`);
root.style.setProperty('--turn-h', `${turnPin.h}px`);
root.classList.add('is-turning');
};
const releaseTurn = function() {
if (!turnPin) return;
turnPin = null;
const root = getRoot();
if (!root) return;
root.classList.remove('is-turning');
root.style.removeProperty('--turn-w');
root.style.removeProperty('--turn-h');
};
// 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
@@ -1318,12 +1401,23 @@ App.feed = App.feed || {};
state.feedActiveIndex = index;
const h = slideHeight();
const start = windowBounds(index).start;
// Only write what actually differs. This runs on every frame of a
// turn, and a spacer height or a scrollTop written back unchanged
// still costs a layout and still sets the snap container off again --
// the page fighting itself at the moment it is busiest.
const spacer = getTopSpacer();
if (spacer) spacer.style.height = `${start * h}px`;
const spacerHeight = `${start * h}px`;
if (spacer && spacer.style.height !== spacerHeight) spacer.style.height = spacerHeight;
const scroller = getScroller();
if (scroller) scroller.scrollTop = index * h;
const top = index * h;
if (scroller && Math.abs(scroller.scrollTop - top) > 0.5) scroller.scrollTop = top;
const activeSlide = slidesByIndex.get(index);
if (activeSlide) measureFeedTitle(activeSlide);
// The title only needs re-measuring when the screen it has to fit in
// changed size, not sixty times while it doesn't.
if (activeSlide && h !== lastMeasuredHeight) {
lastMeasuredHeight = h;
measureFeedTitle(activeSlide);
}
};
// Ends the realign window: one last realign, then real swipes count again.
@@ -1336,8 +1430,10 @@ App.feed = App.feed || {};
clearTimeout(resizeSettleTimer);
resizeSettleTimer = null;
}
releaseTurn();
realignToActive();
suppressScroll = false;
lastShape = viewportShape();
const after = settleDone;
settleDone = null;
if (after) after();
@@ -1351,12 +1447,27 @@ App.feed = App.feed || {};
suppressScroll = false;
return;
}
const height = slideHeight();
if (height === settleHeight) settleStableFrames += 1;
// The viewport's own height, not the pinned one: the pin is a fixed
// number and would look settled from the first frame.
const shape = viewportShape();
if (shape.h === settleHeight) settleStableFrames += 1;
else {
settleHeight = height;
settleHeight = shape.h;
settleStableFrames = 0;
}
// A turn arrives in several steps, and the first of them can be small
// enough to pass for the chrome sliding; it is the shape it ends up in
// that says what it was.
if (!turnPin && !turnResolved && isTurn(settleFromShape, shape)) {
pinTurn(settleFromShape);
}
// The viewport has stopped moving, so what it is now is better than
// what the pin guessed it would be. Letting go here, with the realign
// below in the same frame, is the one step the picture takes.
if (turnPin && settleStableFrames >= TURN_HOLD_FRAMES) {
releaseTurn();
turnResolved = true;
}
realignToActive();
if (settleStableFrames < SETTLE_STABLE_FRAMES &&
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
@@ -1368,14 +1479,36 @@ App.feed = App.feed || {};
resizeSettleRaf = requestAnimationFrame(finishSettle);
};
// The device announcing the turn: dim first, measure after. Nothing about
// the viewport has changed yet at this point, so the reel is already on
// its way out when the first new shape lands.
const onOrientation = function() {
turnSignalAt = Date.now();
turnResolved = false;
if (state.feedOpen && state.feedActiveIndex >= 0 && !turnPin) {
pinTurn(viewportShape());
}
onResize();
};
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;
// The shape to compare against is the one from before the turn
// started, not the one from the previous step of it.
if (!resizeSettleTimer) {
settleFromShape = lastShape || viewportShape();
turnResolved = false;
}
settleStartedAt = Date.now();
settleStableFrames = 0;
settleHeight = -1;
lastMeasuredHeight = -1;
if (!turnPin && !turnResolved && isTurn(settleFromShape, viewportShape())) {
pinTurn(settleFromShape);
}
realignToActive();
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = requestAnimationFrame(settleStep);
@@ -1765,6 +1898,7 @@ App.feed = App.feed || {};
});
slidesByIndex.clear();
forgetPeeks();
releaseTurn();
resumeTimes.clear();
state.feedActiveIndex = -1;
state.feedActiveVideoId = null;
@@ -1800,6 +1934,7 @@ App.feed = App.feed || {};
container.classList.add('open');
container.setAttribute('aria-hidden', 'false');
lastShape = viewportShape();
document.body.classList.add('feed-mode-open');
document.body.style.overflow = 'hidden';
@@ -1810,7 +1945,7 @@ App.feed = App.feed || {};
// 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);
window.addEventListener('orientationchange', onOrientation);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onResize);
}