Compare commits

...

2 Commits

Author SHA1 Message Date
Simon
c60f5e01dc Re-anchor a moving reel on where it is, not what it was playing
A viewport change re-anchors the reel on the video that is playing,
resolved by id rather than by a scroll position the change has already
invalidated. That is what keeps a rotation on the same video.

Mid-swipe it names the wrong one. setActive runs from onScroll's next
frame, so a resize landing before that frame still reads the video being
swiped *away from* -- and realignToActive writes its position straight to
scrollTop, hauling the reel back to it. A phone slides its chrome on every
scroll, so the resize lands mid-swipe on nearly every swipe: the reel went
where the thumb put it, far enough to show the top of the next video at
the bottom of the screen, and was then written back a beat later.

The input guard in onScroll could not catch this. There is no scroll event
to guard -- the write happens inside the resize.

So while the reader is demonstrably driving, take the reel's own position
as the authority and re-anchor on the step it is nearest at the new
height. It still lands on a snap point, so nothing is left straddling two
screens, but the swipe stands. Only a step's worth of disagreement counts
as theirs: a scroller just re-parented into or out of the
picture-in-picture window reads 0, and following that would land them on
the first video instead of their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 22:26:39 +00:00
Simon
7033288bc4 Hold the slides for the whole turn, not until it first pauses
The slides are held at the size a turn is heading for so the picture steps
once, straight into the shape it keeps, instead of being re-letterboxed
through every shape the viewport passes on its way round.

The hold came off once the height had gone six frames unchanged, which
stood in for "the viewport has stopped moving". It does not: a turn
arrives in steps, and the gap between two of them is routinely longer
than six frames -- on a phone and in a loaded tab alike. So the pin came
off mid-turn, the slides took an intermediate shape, and the picture
jumped twice on the way round. That is the stutter the pin exists to
remove, and TURN_HOLD_FRAMES was meant to be "long enough that a viewport
animating round in steps is not mistaken for one that has arrived" -- six
frames is not.

The device does say when a turn is underway, so believe it: while the
announcement stands the pin holds, and the frame count is left to end the
pins nothing announced, where there is no signal to go on and no turn to
ruin. The realign window has to stop concluding mid-turn too, since
closing it releases the pin by the other door; SETTLE_MAX_MS outlasts the
announcement and still guarantees both of them end.

The cost is that an announced turn keeps the old size until the
announcement lapses rather than letting go the moment the viewport looks
still. That buys the one step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 22:26:20 +00:00
2 changed files with 75 additions and 6 deletions

View File

@@ -1413,16 +1413,40 @@ App.feed = App.feed || {};
// video playing/focused rather than snapping to a neighbour.
const realignToActive = function() {
if (stepCount() === 0) return;
const h = slideHeight();
const scroller = getScroller();
// Anchoring by id is what keeps a rotation on the same video, because
// the scroll position it would otherwise read has been invalidated by
// the very change being answered. But mid-swipe that id names the video
// being *left*: setActive runs from onScroll's next frame, so a resize
// landing before it still reads the old one -- and writing its position
// hauls the reel back to it. A phone slides its chrome on every scroll,
// so that happened on nearly every swipe.
//
// While the reader is demonstrably driving, the reel's own position is
// the better authority: re-anchor on the step it is nearest at the new
// height. That still puts it on a snap point -- no slide left straddling
// two screens -- without undoing the swipe.
let index = state.feedActiveIndex;
if (state.feedActiveVideoId != null) {
const found = (state.loadedVideos || [])
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
if (found >= 0) index = Math.floor(found / paneCount());
}
const driving = Date.now() - lastInputAt < INPUT_GRACE_MS;
if (driving && scroller && h) {
// A swipe moves by a step, so only a step's worth of disagreement
// is the reader's doing. Anything further apart is the position
// being meaningless rather than ahead -- a scroller that has just
// been re-parented into (or out of) the picture-in-picture window
// reads 0, and following that would land the reel on the first
// video instead of theirs.
const atScroll = clampIndex(Math.round(scroller.scrollTop / h));
if (atScroll >= 0 && Math.abs(atScroll - index) <= 1) index = atScroll;
}
index = clampIndex(index);
if (index < 0) return;
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
@@ -1431,7 +1455,6 @@ App.feed = App.feed || {};
const spacer = getTopSpacer();
const spacerHeight = `${start * h}px`;
if (spacer && spacer.style.height !== spacerHeight) spacer.style.height = spacerHeight;
const scroller = getScroller();
const top = index * h;
if (scroller && Math.abs(scroller.scrollTop - top) > 0.5) scroller.scrollTop = top;
const activeSlide = slidesByIndex.get(index);
@@ -1492,15 +1515,29 @@ App.feed = App.feed || {};
if (!turnPin && !turnResolved && isTurn(settleFromShape, shape)) {
pinTurn(settleFromShape);
}
// A turn the device announced is not over because the viewport paused.
// It arrives in steps, and the gap between two of them is routinely
// longer than the handful of frames that stands in for "stopped" --
// which is how the pin came off mid-turn and let the picture take an
// intermediate shape, the one thing it is here to prevent. So while
// the announcement stands the pin holds, and the frame count is left
// to end the pins nothing announced: a window dragged across the
// square, where there is no signal to go on and no turn to ruin.
const announced = Date.now() - turnSignalAt < TURN_SIGNAL_MS;
// 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) {
if (turnPin && !announced && settleStableFrames >= TURN_HOLD_FRAMES) {
releaseTurn();
turnResolved = true;
}
realignToActive();
if (settleStableFrames < SETTLE_STABLE_FRAMES &&
// The window may not conclude mid-turn either: closing it releases the
// pin on the way out, which would put the picture through the same
// intermediate shape by the other door. An announced turn therefore
// holds the window open too, and SETTLE_MAX_MS -- longer than the
// announcement -- is what still guarantees it ends.
if ((announced || settleStableFrames < SETTLE_STABLE_FRAMES) &&
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
resizeSettleRaf = requestAnimationFrame(settleStep);
return;

View File

@@ -90,6 +90,22 @@ RESNAP = """(top) => {
document.getElementById('feed-scroll').scrollTop = top;
}"""
# A swipe, and the chrome reporting itself before the feed has caught up.
# setActive runs from onScroll's next frame, so doing both in one block --
# no frame boundary in between -- leaves feedActiveVideoId still naming the
# video being left, which is the state a phone resize routinely lands in.
# onResize then re-anchors synchronously, with no scroll event for the
# input guard in onScroll to see.
SWIPE_THEN_RESIZE = """() => {
const s = document.getElementById('feed-scroll');
const before = App.state.feedActiveIndex;
s.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }));
s.scrollTop = s.scrollTop + s.clientHeight;
const moved = s.scrollTop;
window.dispatchEvent(new Event('resize'));
return { before: before, moved: moved, after: s.scrollTop, h: s.clientHeight };
}"""
class Checks:
def __init__(self):
@@ -187,6 +203,22 @@ def main():
c.ok("re-anchored onto its own snap point",
abs(resnapped["scrollTop"] - resnapped["expected"]) <= 1, str(resnapped))
print("\nthe chrome reporting itself before the feed has caught up")
page.wait_for_timeout(900)
pre = page.evaluate(WHERE)
got = page.evaluate(SWIPE_THEN_RESIZE)
page.wait_for_timeout(2200)
kept = page.evaluate(WHERE)
print(f" (swiped to {got['moved']}px; the resize left it at {got['after']}px)")
c.ok("the resize did not haul the reel back to the video being left",
got["after"] >= got["moved"] - 1,
f"{got['moved']} -> {got['after']}")
c.ok("the swipe still landed on the next step",
kept["index"] == pre["index"] + 1,
f"step {pre['index']} -> {kept['index']}")
c.ok("and on that step's snap point",
abs(kept["scrollTop"] - kept["expected"]) <= 1, str(kept))
print("\na little height on a near-square viewport is not a rotation")
page.set_viewport_size(SQUARE)
page.wait_for_timeout(1800)
@@ -202,8 +234,8 @@ def main():
c.ok("and the reel rests on its snap point",
abs(settled["scrollTop"] - settled["expected"]) <= 1, str(settled))
c.ok("still on the same video",
settled["videoId"] == resnapped["videoId"],
f"{resnapped['videoId']} -> {settled['videoId']}")
settled["videoId"] == kept["videoId"],
f"{kept['videoId']} -> {settled['videoId']}")
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()