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:
@@ -433,14 +433,18 @@ App.feed = App.feed || {};
|
|||||||
return RENDITION_STEPS.find((step) => step >= needed) || 0;
|
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');
|
const video = slide.querySelector('.feed-video');
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
const silent = !!(opts && opts.silent);
|
||||||
if (slide.classList.contains('is-loaded')) {
|
if (slide.classList.contains('is-loaded')) {
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
// Picks up a stream that was parked after preloading.
|
// Picks up a stream that was parked after preloading.
|
||||||
if (video._hlsPlayer) video._hlsPlayer.startLoad();
|
if (video._hlsPlayer) video._hlsPlayer.startLoad();
|
||||||
video.muted = slide._muted !== false;
|
video.muted = silent || slide._muted !== false;
|
||||||
const playPromise = video.play();
|
const playPromise = video.play();
|
||||||
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -483,7 +487,7 @@ App.feed = App.feed || {};
|
|||||||
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||||
const isHls = App.videos.classifySource(resolved).isHls;
|
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
|
// The step being watched buffers properly; the one preloaded behind it
|
||||||
// only needs enough to start instantly on the swipe. With four panes
|
// only needs enough to start instantly on the swipe. With four panes
|
||||||
// that is the difference between four extra streams downloading and
|
// that is the difference between four extra streams downloading and
|
||||||
@@ -640,6 +644,7 @@ App.feed = App.feed || {};
|
|||||||
slide.remove();
|
slide.remove();
|
||||||
});
|
});
|
||||||
slidesByIndex.clear();
|
slidesByIndex.clear();
|
||||||
|
forgetPeeks();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Everything one video needs on screen. Identical to what a slide used to
|
// Everything one video needs on screen. Identical to what a slide used to
|
||||||
@@ -1043,6 +1048,110 @@ App.feed = App.feed || {};
|
|||||||
prefetchIfNeeded(clamped);
|
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() {
|
const onScroll = function() {
|
||||||
wakeHud();
|
wakeHud();
|
||||||
if (scrollRaf) return;
|
if (scrollRaf) return;
|
||||||
@@ -1053,6 +1162,9 @@ App.feed = App.feed || {};
|
|||||||
// Ignore scroll events fired by a resize/orientation re-snap; the
|
// Ignore scroll events fired by a resize/orientation re-snap; the
|
||||||
// active video is realigned by onResize instead (see suppressScroll).
|
// active video is realigned by onResize instead (see suppressScroll).
|
||||||
if (suppressScroll) return;
|
if (suppressScroll) return;
|
||||||
|
peekIncoming(scroller);
|
||||||
|
if (peekSettleTimer) clearTimeout(peekSettleTimer);
|
||||||
|
peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS);
|
||||||
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
if (index !== state.feedActiveIndex) {
|
if (index !== state.feedActiveIndex) {
|
||||||
@@ -1484,6 +1596,7 @@ App.feed = App.feed || {};
|
|||||||
slide.remove();
|
slide.remove();
|
||||||
});
|
});
|
||||||
slidesByIndex.clear();
|
slidesByIndex.clear();
|
||||||
|
forgetPeeks();
|
||||||
resumeTimes.clear();
|
resumeTimes.clear();
|
||||||
state.feedActiveIndex = -1;
|
state.feedActiveIndex = -1;
|
||||||
state.feedActiveVideoId = null;
|
state.feedActiveVideoId = null;
|
||||||
|
|||||||
148
tests/smoke_peek.py
Normal file
148
tests/smoke_peek.py
Normal 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())
|
||||||
Reference in New Issue
Block a user