Start the next reel as it slides in, not once it has landed

A step began playing when the swipe was already half done -- Math.round of
the scroll position -- and only then started loading. So it slid into view
as a still picture and came to life some time after it arrived, which is
most of the waiting in a reel.

The step coming into view now starts as soon as any of it is showing, and
silently, so it doesn't talk over the one still being watched; its own
sound setting is restored when it becomes the step being watched. A swipe
carried through therefore lands on a video that is already running.

A swipe that is abandoned leaves nothing behind: the step peeked at is
stopped and put back where the peek found it -- the start for a step never
watched, where it was left for one scrolled past -- so the next swipe to it
begins at the top rather than partway through something nobody saw.

"The scroll has stopped" has to mean stopped *on* a step. Scroll events also
stop while a finger holds the list between two of them, and the first cut
rewound the incoming video under the held finger -- which is exactly the
stall this removes. A settle that finds the scroller mid-step now waits for
the snap to land.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
Simon
2026-09-21 17:36:57 +00:00
parent b8a51d9ee2
commit 39a47289b9
2 changed files with 264 additions and 3 deletions

148
tests/smoke_peek.py Normal file
View File

@@ -0,0 +1,148 @@
#!/usr/bin/env python3
"""Reels peeking: the next step plays as it slides in, and rewinds if it doesn't.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_peek.py
A half-finished swipe is the thing under test, so the scroller's snapping is
switched off for the duration: a finger holds the list between two steps, and
`scrollTop` can only do the same with mandatory snapping out of the way.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
# Every step in the window, by its index, with what its video is doing.
STEPS = """() => {
const out = {};
document.querySelectorAll('.feed-slide').forEach((slide) => {
const video = slide.querySelector('.feed-video');
out[slide.dataset.step] = {
active: slide.classList.contains('is-active'),
loaded: !!slide.querySelector('.feed-pane.is-loaded'),
paused: video ? video.paused : null,
muted: video ? video.muted : null,
t: video ? Math.round(video.currentTime * 10) / 10 : null,
};
});
return { steps: out, activeIndex: App.state.feedActiveIndex };
}"""
DRAG = """(fraction) => {
const scroller = document.getElementById('feed-scroll');
// A finger can hold the list between two steps; scrollTop can only do the
// same with mandatory snapping out of the way.
scroller.style.scrollSnapType = 'none';
const height = scroller.clientHeight;
scroller.scrollTop = Math.round(height * fraction);
return scroller.scrollTop;
}"""
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 open_reels(page):
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(1500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(4000)
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={"width": 430, "height": 930})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
open_reels(page)
start = page.evaluate(STEPS)
c.ok("the first step is playing", start["steps"]["0"]["paused"] is False, str(start))
print("\na swipe barely begun")
page.evaluate(DRAG, 0.18)
page.wait_for_timeout(1800)
peek = page.evaluate(STEPS)
nxt = peek["steps"].get("1")
c.ok("the step sliding in is already playing", nxt and nxt["paused"] is False, str(peek))
c.ok("silently, so it doesn't talk over the one being left",
nxt and nxt["muted"] is True, str(nxt))
c.ok("and the one being left plays on",
peek["steps"]["0"]["paused"] is False, str(peek["steps"]["0"]))
c.ok("without the swipe counting as done", peek["activeIndex"] == 0,
str(peek["activeIndex"]))
print("\nthinking better of it")
page.evaluate(DRAG, 0)
page.wait_for_timeout(1200)
page.evaluate("() => App.feed._settlePeek()")
page.wait_for_timeout(400)
back = page.evaluate(STEPS)
nxt = back["steps"].get("1")
c.ok("the step that slid back in is stopped", nxt and nxt["paused"] is True, str(nxt))
c.ok("and wound back to the start", nxt and nxt["t"] == 0, str(nxt))
c.ok("the one being watched never stopped",
back["steps"]["0"]["paused"] is False, str(back["steps"]["0"]))
print("\nswiping again starts it from the top")
page.evaluate(DRAG, 0.2)
page.wait_for_timeout(1500)
again = page.evaluate(STEPS)["steps"].get("1")
c.ok("it plays again", again and again["paused"] is False, str(again))
c.ok("from the beginning, not from where the first peek got to",
again and again["t"] is not None and again["t"] < 2.5, str(again))
print("\ncarrying the swipe through")
page.evaluate(DRAG, 1)
page.wait_for_timeout(2500)
page.evaluate("() => App.feed._settlePeek()")
page.wait_for_timeout(400)
done = page.evaluate(STEPS)
arrived = done["steps"].get("1")
c.ok("the step arrived at is the active one", done["activeIndex"] == 1, str(done["activeIndex"]))
c.ok("it is playing", arrived and arrived["paused"] is False, str(arrived))
c.ok("it was never wound back", arrived and arrived["t"] > 0, str(arrived))
c.ok("and it carries the feed's own sound setting",
arrived and arrived["muted"] == page.evaluate("() => App.state.feedMuted !== false"),
str(arrived))
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())