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;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user