From 39a47289b9454d22492604a89fc98543baf74c09 Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 21 Sep 2026 17:36:57 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN --- frontend/js/feed.js | 119 ++++++++++++++++++++++++++++++++++- tests/smoke_peek.py | 148 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 tests/smoke_peek.py diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 2def2db..33eb386 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -433,14 +433,18 @@ App.feed = App.feed || {}; return RENDITION_STEPS.find((step) => step >= needed) || 0; }; - const loadSlideSource = function(slide, videoData, autoplay) { + // `opts.silent` plays without sound whatever the panel's own switch says -- + // for a panel that is only half on screen (see peek), whose audio would + // otherwise talk over the one still being watched. + const loadSlideSource = function(slide, videoData, autoplay, opts) { const video = slide.querySelector('.feed-video'); if (!video) return; + const silent = !!(opts && opts.silent); if (slide.classList.contains('is-loaded')) { if (autoplay) { // Picks up a stream that was parked after preloading. if (video._hlsPlayer) video._hlsPlayer.startLoad(); - video.muted = slide._muted !== false; + video.muted = silent || slide._muted !== false; const playPromise = video.play(); if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {}); } @@ -483,7 +487,7 @@ App.feed = App.feed || {}; const streamUrl = App.videos.buildStreamUrlFromSource(resolved); const isHls = App.videos.classifySource(resolved).isHls; - video.muted = slide._muted !== false; + video.muted = silent || slide._muted !== false; // The step being watched buffers properly; the one preloaded behind it // only needs enough to start instantly on the swipe. With four panes // that is the difference between four extra streams downloading and @@ -640,6 +644,7 @@ App.feed = App.feed || {}; slide.remove(); }); slidesByIndex.clear(); + forgetPeeks(); }; // Everything one video needs on screen. Identical to what a slide used to @@ -1043,6 +1048,110 @@ App.feed = App.feed || {}; prefetchIfNeeded(clamped); }; + // ------------------------------------------------------------------ + // Peeking + // + // A step used to start playing when the swipe was already half done -- + // `Math.round` of the scroll position -- and then still had to load. So + // the video slid into view as a still picture and began some time after + // it arrived, which is most of the waiting in a reel. + // + // Now the step coming into view starts as soon as any of it is showing, + // silently, while the one being left plays on. A swipe that is carried + // through therefore lands on a video that is already running; one that is + // abandoned leaves nothing behind -- the step that was peeked at is + // stopped and wound back, so the next swipe to it starts it from the top + // rather than from the second and a half nobody watched. + // ------------------------------------------------------------------ + const PEEK_MIN_PX = 8; // less than this is a twitch, not a swipe + const PEEK_SETTLE_MS = 140; // quiet for this long means the scroll is over + const peeked = new Set(); // step elements started by a peek + let peekSettleTimer = null; + + const startPeek = function(index) { + const slide = slidesByIndex.get(index); + if (!slide || peeked.has(slide)) return; + peeked.add(slide); + panesOf(slide).forEach((pane) => { + const video = pane.querySelector('.feed-video'); + const id = slideVideoId(pane); + // Where this panel stood before anyone looked at it, so an + // abandoned swipe can put it back exactly there: the start for a + // step never watched, and where it was left for one scrolled past. + pane._peekFrom = (video && video.currentTime) || + (id != null && resumeTimes.get(id)) || 0; + loadSlideSource(pane, pane._videoData, true, { silent: true }); + }); + }; + + // Stops a panel and puts it back where the peek found it -- the opposite + // of pauseSlide, which keeps where it got to on purpose. + const rewindPane = function(pane) { + const video = pane.querySelector('.feed-video'); + const back = pane._peekFrom || 0; + pane.classList.remove('is-active'); + if (video) { + if (!video.paused) video.pause(); + try { video.currentTime = back; } catch (err) { /* not seekable yet */ } + } + const id = slideVideoId(pane); + if (id != null) { + if (back > 0) resumeTimes.set(id, back); + else resumeTimes.delete(id); + } + pane._peekFrom = 0; + }; + + // The scroll has stopped somewhere: whatever was peeked at and not arrived + // at goes back to where it started. + // + // "Stopped" has to mean stopped *on* a step. Scroll events also stop while + // a finger holds the list still between two of them -- the swipe is then + // neither finished nor abandoned, and winding the incoming step back under + // a held finger is precisely the stall this is here to remove. + const settlePeek = function() { + peekSettleTimer = null; + if (!peeked.size) return; + const scroller = getScroller(); + const height = slideHeight(); + if (scroller && height) { + const offset = scroller.scrollTop % height; + if (offset > PEEK_MIN_PX && offset < height - PEEK_MIN_PX) return; + } + const active = slidesByIndex.get(state.feedActiveIndex); + peeked.forEach((slide) => { + if (slide === active) return; + panesOf(slide).forEach(rewindPane); + }); + peeked.clear(); + }; + + // Nothing to put back once the steps themselves are gone. + const forgetPeeks = function() { + if (peekSettleTimer) { + clearTimeout(peekSettleTimer); + peekSettleTimer = null; + } + peeked.clear(); + }; + + App.feed._settlePeek = settlePeek; // tests drive the settle directly + + const peekIncoming = function(scroller) { + const height = slideHeight(); + if (!height) return; + const first = Math.floor(scroller.scrollTop / height); + const offset = scroller.scrollTop - first * height; + if (offset < PEEK_MIN_PX) return; // nothing else is showing yet + // Two steps are on screen; the one that isn't being watched is the one + // arriving, whichever direction the swipe is going. + [first, first + 1].forEach((index) => { + if (index === state.feedActiveIndex) return; + if (index < 0 || index >= stepCount()) return; + startPeek(index); + }); + }; + const onScroll = function() { wakeHud(); if (scrollRaf) return; @@ -1053,6 +1162,9 @@ App.feed = App.feed || {}; // Ignore scroll events fired by a resize/orientation re-snap; the // active video is realigned by onResize instead (see suppressScroll). if (suppressScroll) return; + peekIncoming(scroller); + if (peekSettleTimer) clearTimeout(peekSettleTimer); + peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS); const index = clampIndex(Math.round(scroller.scrollTop / slideHeight())); if (index < 0) return; if (index !== state.feedActiveIndex) { @@ -1484,6 +1596,7 @@ App.feed = App.feed || {}; slide.remove(); }); slidesByIndex.clear(); + forgetPeeks(); resumeTimes.clear(); state.feedActiveIndex = -1; state.feedActiveVideoId = null; diff --git a/tests/smoke_peek.py b/tests/smoke_peek.py new file mode 100644 index 0000000..8f76c35 --- /dev/null +++ b/tests/smoke_peek.py @@ -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())