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

@@ -2236,6 +2236,27 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
height: 1px; height: 1px;
} }
/* A turn goes through every shape between the one it left and the one it
lands in, and each of those re-letterboxes the picture somewhere else --
a run of jumps where there should be one. So for the length of the turn
the slides are held at the size it is heading for (feed.js sets the two
numbers): the picture moves once, straight into its final position and
size, and stays there while the viewport catches up to it. */
.feed-view.is-turning .feed-slide {
width: var(--turn-w);
height: var(--turn-h);
}
.feed-view.is-turning .feed-scroll {
/* The pinned slides are a different shape from the viewport until the turn
finishes, and none of that overflow is for scrolling through. Snapping
is off with it: the container would otherwise be re-anchoring on the old
height at the same time as the feed is pinning itself to the new one. */
overflow: hidden;
scroll-snap-type: none;
scroll-behavior: auto;
}
/* Stands in for the slides above the rendered window so scroll position and /* Stands in for the slides above the rendered window so scroll position and
snap points stay stable while slides are virtualized in and out. */ snap points stay stable while slides are virtualized in and out. */
.feed-top-spacer { .feed-top-spacer {

View File

@@ -60,6 +60,35 @@ App.feed = App.feed || {};
let settleStableFrames = 0; let settleStableFrames = 0;
let settleHeight = -1; let settleHeight = -1;
let settleDone = null; // run once the window closes, if anything asked 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 // HUD auto-hide: the reels HUD fades out after this much inactivity and
// reappears on any pointer movement / tap / scroll. Buttons keep their // 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 getTopSpacer = () => inRoot('feed-top-spacer');
const slideHeight = function() { 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 scroller = getScroller();
const view = feedDocument().defaultView || window; const view = feedDocument().defaultView || window;
return (scroller && scroller.clientHeight) || view.innerHeight || 1; return (scroller && scroller.clientHeight) || view.innerHeight || 1;
@@ -1281,6 +1314,14 @@ App.feed = App.feed || {};
}; };
const onScroll = function() { 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; if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => { scrollRaf = requestAnimationFrame(() => {
scrollRaf = null; 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 // 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 // viewport changes. The active slide is resolved by id (not by a possibly
// stale scroll position) so an orientation change always keeps the same // stale scroll position) so an orientation change always keeps the same
@@ -1318,12 +1401,23 @@ App.feed = App.feed || {};
state.feedActiveIndex = index; state.feedActiveIndex = index;
const h = slideHeight(); const h = slideHeight();
const start = windowBounds(index).start; 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(); 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(); 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); 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. // Ends the realign window: one last realign, then real swipes count again.
@@ -1336,8 +1430,10 @@ App.feed = App.feed || {};
clearTimeout(resizeSettleTimer); clearTimeout(resizeSettleTimer);
resizeSettleTimer = null; resizeSettleTimer = null;
} }
releaseTurn();
realignToActive(); realignToActive();
suppressScroll = false; suppressScroll = false;
lastShape = viewportShape();
const after = settleDone; const after = settleDone;
settleDone = null; settleDone = null;
if (after) after(); if (after) after();
@@ -1351,12 +1447,27 @@ App.feed = App.feed || {};
suppressScroll = false; suppressScroll = false;
return; return;
} }
const height = slideHeight(); // The viewport's own height, not the pinned one: the pin is a fixed
if (height === settleHeight) settleStableFrames += 1; // number and would look settled from the first frame.
const shape = viewportShape();
if (shape.h === settleHeight) settleStableFrames += 1;
else { else {
settleHeight = height; settleHeight = shape.h;
settleStableFrames = 0; 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(); realignToActive();
if (settleStableFrames < SETTLE_STABLE_FRAMES && if (settleStableFrames < SETTLE_STABLE_FRAMES &&
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) { (Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
@@ -1368,14 +1479,36 @@ App.feed = App.feed || {};
resizeSettleRaf = requestAnimationFrame(finishSettle); 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() { const onResize = function() {
if (!state.feedOpen || state.feedActiveIndex < 0) return; if (!state.feedOpen || state.feedActiveIndex < 0) return;
// Suppress scroll handling while we realign so the container's re-snap // 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. // doesn't flip the active video, then re-enable it once layout settles.
suppressScroll = true; 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(); settleStartedAt = Date.now();
settleStableFrames = 0; settleStableFrames = 0;
settleHeight = -1; settleHeight = -1;
lastMeasuredHeight = -1;
if (!turnPin && !turnResolved && isTurn(settleFromShape, viewportShape())) {
pinTurn(settleFromShape);
}
realignToActive(); realignToActive();
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf); if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
resizeSettleRaf = requestAnimationFrame(settleStep); resizeSettleRaf = requestAnimationFrame(settleStep);
@@ -1765,6 +1898,7 @@ App.feed = App.feed || {};
}); });
slidesByIndex.clear(); slidesByIndex.clear();
forgetPeeks(); forgetPeeks();
releaseTurn();
resumeTimes.clear(); resumeTimes.clear();
state.feedActiveIndex = -1; state.feedActiveIndex = -1;
state.feedActiveVideoId = null; state.feedActiveVideoId = null;
@@ -1800,6 +1934,7 @@ App.feed = App.feed || {};
container.classList.add('open'); container.classList.add('open');
container.setAttribute('aria-hidden', 'false'); container.setAttribute('aria-hidden', 'false');
lastShape = viewportShape();
document.body.classList.add('feed-mode-open'); document.body.classList.add('feed-mode-open');
document.body.style.overflow = 'hidden'; 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 // the visual viewport moves on its own as the browser's chrome
// slides away, so both open the realign window too -- the watcher // slides away, so both open the realign window too -- the watcher
// below waits for the height to stop moving either way. // below waits for the height to stop moving either way.
window.addEventListener('orientationchange', onResize); window.addEventListener('orientationchange', onOrientation);
if (window.visualViewport) { if (window.visualViewport) {
window.visualViewport.addEventListener('resize', onResize); window.visualViewport.addEventListener('resize', onResize);
} }

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""Turning the phone moves the picture once, not a dozen times.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_rotate_smooth.py
Its sibling smoke_rotate.py asks that a turn keep the same video. This one
asks what the turn looks like while it happens.
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 one re-letterboxes
the picture somewhere else. Painted, that run of jumps is the stutter. So the
slides are held at the size the turn is heading for from its first frame: the
picture steps straight into the position and size it will keep, and stays
there -- no fade, no intermediate shapes, nothing dimmed.
A viewport that only changes height is the browser's chrome sliding away, not
a wrist, and must not be pinned: every scroll on a phone does that.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "clip.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
PORTRAIT = {"width": 430, "height": 930}
LANDSCAPE = {"width": 930, "height": 430}
# The shapes a viewport goes through on its way round.
TURN = [{"width": 560, "height": 800}, {"width": 700, "height": 660},
{"width": 830, "height": 520}, LANDSCAPE]
# The chrome sliding away: height only, and nothing announced it.
CHROME = {"width": 430, "height": 830}
STATUS = {"online": True, "channels": [{
"id": CHANNEL, "name": "Fixture Clips",
"description": "A channel that only exists for this test.", "options": []}]}
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
# Samples every frame: how much of the reel is on screen, what shape the
# picture has, and whether it is still where it belongs.
WATCH = """() => {
window.__seen = [];
window.__watch = true;
const scroller = document.getElementById('feed-scroll');
const tick = function() {
const active = document.querySelector('.feed-slide.is-active');
const video = active ? active.querySelector('.feed-video') : null;
const box = video ? video.getBoundingClientRect() : null;
window.__seen.push({
opacity: Number(getComputedStyle(scroller).opacity),
pinned: document.getElementById('feed-view').classList.contains('is-turning'),
shape: box ? [Math.round(box.width), Math.round(box.height)] : null,
top: active ? Math.round(active.getBoundingClientRect().top) : null,
paused: video ? video.paused : null
});
if (window.__watch) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}"""
STOP = "() => { window.__watch = false; return window.__seen; }"
REST = """() => {
const scroller = document.getElementById('feed-scroll');
const style = getComputedStyle(scroller);
return {
opacity: Number(style.opacity),
snap: style.scrollSnapType,
overflow: style.overflowY,
shape: (function() {
const box = document.querySelector('.feed-slide.is-active .feed-video');
const rect = box ? box.getBoundingClientRect() : null;
return rect ? [Math.round(rect.width), Math.round(rect.height)] : null;
})(),
turning: document.getElementById('feed-view').classList.contains('is-turning'),
videoId: App.state.feedActiveVideoId,
index: App.state.feedActiveIndex,
scrollTop: Math.round(scroller.scrollTop),
expected: App.state.feedActiveIndex * scroller.clientHeight
};
}"""
GO = """(step) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = step * scroller.clientHeight;
}"""
# The container's own re-anchor, on a position measured against the height
# from before the turn, arriving while the new one is already in place.
STALE = """(oldHeight) => {
const scroller = document.getElementById('feed-scroll');
scroller.scrollTop = App.state.feedActiveIndex * oldHeight;
}"""
class Checks:
def __init__(self):
self.failed = 0
def ok(self, label, condition, detail=""):
mark = "PASS" if condition else "FAIL"
if not condition:
self.failed += 1
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
def items():
return [{"id": f"vid-{i}", "title": f"clip {i}",
"url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
"duration": 30, "isLive": False, "tags": []} for i in range(36)]
def shape_changes(seen):
"""The frames where the picture changed shape, with how lit they were."""
out = []
for a, b in zip(seen, seen[1:]):
if a["shape"] and b["shape"] and a["shape"] != b["shape"]:
out.append({"from": a["shape"], "to": b["shape"], "opacity": b["opacity"]})
return out
def do_turn(page, sizes, old_height, announced=True):
page.evaluate(WATCH)
page.wait_for_timeout(120)
if announced:
page.evaluate("() => window.dispatchEvent(new Event('orientationchange'))")
for i, size in enumerate(sizes):
page.set_viewport_size(size)
if i == 1:
page.evaluate(STALE, old_height)
page.wait_for_timeout(70)
page.wait_for_timeout(1400)
return page.evaluate(STOP)
def main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
"--autoplay-policy=no-user-gesture-required",
"--renderer-process-limit=1", "--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport=PORTRAIT)
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
page.route(re.compile(r"\.mp4"), lambda route: route.fulfill(
status=200, content_type="video/mp4", body=open(CLIP, "rb").read()))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(800)
for step in (1, 2, 3):
page.evaluate(GO, step)
page.wait_for_timeout(250)
page.wait_for_timeout(800)
before = page.evaluate(REST)
print("\nturning it sideways, one shape at a time")
seen = do_turn(page, TURN, PORTRAIT["height"])
changes = shape_changes(seen)
rest = page.evaluate(REST)
c.ok("the picture moved once, not through every shape on the way",
len(changes) == 1, str(changes))
c.ok("and the one step it took was straight into the size it keeps",
bool(changes) and changes[0]["to"] == rest["shape"],
f"{changes} -> {rest['shape']}")
c.ok("nothing was dimmed to cover it",
all(s["opacity"] == 1 for s in seen),
str(min(s["opacity"] for s in seen)))
c.ok("the reel stayed pinned to its step the whole way",
all(s["top"] in (None, 0) for s in seen),
str(sorted({s["top"] for s in seen})))
c.ok("the slides were held only for the turn, not after it",
0 < len([s for s in seen if s["pinned"]]) < len(seen) * 0.75,
f"{len([s for s in seen if s['pinned']])} of {len(seen)} frames")
c.ok("the clip never paused for it",
not any(s["paused"] for s in seen), "")
c.ok("and the feed is no longer marked as turning", not rest["turning"], str(rest))
c.ok("snapping is back on", "y" in rest["snap"], str(rest["snap"]))
c.ok("and so is scrolling", rest["overflow"] == "scroll", str(rest["overflow"]))
c.ok("on the same video it was on",
rest["videoId"] == before["videoId"],
f"{before['videoId']} -> {rest['videoId']}")
c.ok("sitting on its snap point",
abs(rest["scrollTop"] - rest["expected"]) <= 1, str(rest))
print("\nand back upright")
seen = do_turn(page, list(reversed(TURN[:-1])) + [PORTRAIT], LANDSCAPE["height"])
changes = shape_changes(seen)
back = page.evaluate(REST)
c.ok("one step on the way back too", len(changes) == 1, str(changes))
c.ok("into the size it keeps",
bool(changes) and changes[0]["to"] == back["shape"],
f"{changes} -> {back['shape']}")
c.ok("still the same video", back["videoId"] == before["videoId"], str(back))
print("\nthe browser's chrome sliding away is not a turn")
seen = do_turn(page, [CHROME], PORTRAIT["height"], announced=False)
c.ok("the slides were never pinned for it",
not any(s["pinned"] for s in seen), "")
page.set_viewport_size(PORTRAIT)
page.wait_for_timeout(1200)
chrome = page.evaluate(REST)
c.ok("and it is still on the same video",
chrome["videoId"] == before["videoId"], str(chrome))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
sys.exit(main())