Four panels stutter, and the reason isn't scheduling: a quarter-screen
panel was still being handed a full-screen stream. Decoding 1080p into a
quarter of the screen costs exactly what decoding it full size costs, and
four of those at once is past what most GPUs will decode in hardware --
after which it falls back to software and the wheels come off.
So a split panel now caps by its own height in device pixels, rounded up
to the next standard rendition, and hls.js is told the same thing through
capLevelToPlayerSize since an adaptive stream picks its own. Four panels
on a 1080p screen land near 480p each: roughly a quarter of the pixels to
decode. Its buffers shrink too -- several instances each holding a minute
of video is memory and demuxing for footage nobody has reached.
The preloaded step keeps its guarantee but gets cheaper with it: those
panes use preload=metadata rather than auto, so every panel still has its
next video ready to start instantly without four more streams competing
for bandwidth with the four being watched.
The floors that make that preload guarantee hold -- one step, in both
windowBounds and preloadAhead -- now say so. Both are divided by the pane
count, and dropping either below one would leave a panel with nothing
buffered to swipe to.
Two tests. tests/unit_formats.js runs the rendition maths in node with no
browser, server or network, in under a second: picking a format is a list
in and a URL out, and it is the cheapest thing in the repo to assert.
tests/smoke_reels.py covers the panels themselves -- splitting, nesting,
controls staying inside short panes, one swipe advancing every panel,
per-panel audio surviving re-activation, and the preload guarantee.
What none of this establishes is whether four streams now play smoothly
on real hardware. Headless Chromium has no GPU decode, so it cannot say.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
DOM work cannot leave the main thread -- a worker has no document, and
nodes are not transferable -- so a card can never be compiled elsewhere.
It can be compiled *earlier*. The page already prefetches the next page's
JSON and warms its thumbnails; this does the same for the cards those
items will need, building and binding them while the browser is idle and
handing them over ready when the reader arrives.
Alongside that, three things that keep a frame from being held too long,
which is what smoothness actually reduces to when the work has nowhere
else to go:
Mounting and filling are drained against a 4ms budget rather than all at
once. Filling in one pass was a regression I introduced with the fling
deferral: it moved the stall from during the fling to the end of it.
A frame's mounts go into a DocumentFragment and enter the document in one
insertion, with filling afterwards so nothing reads layout mid-insert.
The pool is topped up with card shells during idle, so a mount during a
scroll is a rebind and not a construction: cards built mid-scroll fell
from 24 to 5 across profiling runs.
Also reverted, with its numbers kept in a comment: narrowing the overscan
during a fling. It reads like an obvious saving and measures as the
opposite -- a tight window makes cards leave and re-enter it, and mount
churn went from 88 to 155 with blocked time from 3.6s to 4.3s.
What this does not do is reduce total blocked time. Roughly three
quarters of it is browser style, layout, paint and decode for each card
shown, which no amount of scheduling removes. The deep stalls get
shorter; the thread stays busy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
Profiling the reported stutter on a fixed 400-card grid: 30 cards mounted
during one fast scroll, 693ms of blocked main thread -- about 23ms per
card. Building the card is 15.6ms of that. The rest is what a mount sets
off: a thumbnail request and decode, the height correction its load
triggers, a forced layout to measure the title, and an /api/resolve call
that runs yt-dlp on the server. Measured separately, a single fast scroll
fired twelve of those, peaking at nine a second, for videos the reader
never stopped on.
None of it is work anyone asked for while the list is moving. So a mount
during a fling now only places the card: right size, right position, text
and heart in place, so the grid and the scrollbar stay exactly correct.
Everything that costs waits 140ms for the scroll to settle, and then runs
only for the cards still on screen. Whatever was scrolled past is
unmounted having cost almost nothing.
The threshold is 1600px/s, well above a deliberate scroll, so reading at
a normal pace behaves as it did. The visible trade is that flinging
through a long list shows card text over an empty thumbnail box until you
slow down.
Roughly half the blocked time was browser style, layout, paint and decode
that no amount of restructuring removes -- this avoids provoking that work
for cards nobody looks at, rather than making it cheaper.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
A second review pass over the pool. All three are the same shape: state
or a listener outliving the video it belonged to.
attachProxyFallback replaces whatever fallback an image currently has,
so a call arriving late -- a race hitting its patience timeout after the
card was recycled -- took away the live listener and left a dead one, and
the new video's thumbnail would then fail with nothing behind it. This
one was self-inflicted: the detach came in last round to stop the
listeners accumulating, and introduced the clobber. It is token-guarded
now, like every other path that can arrive late.
The favourite pop is cleared by animationend, which never fires on a card
release() has already detached -- detached elements run no animations. So
the class rode into the pool and replayed on the next video the card
showed. Favourite something and flick-scroll to see it.
And withOrigin compared a token that dataset reports as undefined for an
unstamped card, which matched every unstamped card instead of none --
failing open in the guard whose whole purpose is noticing that the grid
has recycled the card out from under the player.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
Scrolling the grid did nothing but destroy cards and build near-identical
ones back: a template string parsed as innerHTML, ten querySelectors, and
a listener per interactive element, every time a card entered the window.
The virtualizer now keeps a pool and rebinds a card it already has --
18us against 136us to build one, and 74-83% of mounts are served from it.
Two things had to change first. Nothing on a card may close over the
video it is showing, because the card outlives the video, so every
interaction moved to one delegated listener per event type on the grid.
And every card now has the same shape whatever it shows: the optional
parts are always present and hidden when unused, so any pooled card fits
any video. That needed a global [hidden] rule, since .live-badge and
.video-tags carry their own display.
The rest is the release path, which is where this design lives or dies.
A thumbnail carries a generation, so a race or a proxy fallback settling
after the card moved on cannot paint over the video now showing. The
player stamps the card it was opened from, so a recycled element stops
answering for it. The reveal handler, the entrance-animation listener and
the hover preview are all taken back off. Anything missed here surfaces
as one video's title, thumbnail or heart on another video's card, which
is what the smoke suite scrolls back and forth to catch.
Two incidental fixes found while measuring: bindCard no longer writes a
data-tag per tag button (dataset is a proxy, and that alone cost more
than the rest of a rebind put together -- the handler reads the label off
the button), and favorites.has no longer parses a URL for every card that
isn't a favorite by key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
The repo has no tests, and the card pool that follows is exactly the kind of
change that breaks quietly: a release path that forgets to clear something
shows one video's title, thumbnail or heart on another video's card. So the
check that matters is that every mounted card renders the video its own
data-video-id names -- asserted after scrolling down and back, which is when
cards get reused.
Passes against the current build, where nothing is recycled yet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd