Commit Graph

9 Commits

Author SHA1 Message Date
Simon
191c81e55e Reach picture-in-picture the way iOS offers it
Safari on iPhone and iPad has picture-in-picture but never implemented
requestPictureInPicture; it exposes WebKit's older presentation-mode switch
instead, and document.pictureInPictureEnabled is undefined there. Every
capability check in the player and the feed was that one property, so they
all answered "no" and the button was hidden outright on the platform where
people most want it.

The difference is confined to customPlayer: supportsPiP, pipElement, enterPiP
and exitPiP speak for both APIs, and bindPiPEvents re-fires WebKit's
webkitpresentationmodechanged -- which does not bubble -- as the standard
enter/leave events, so the feed's delegated listeners, the pane pin and the
media-key stepping work unchanged. Capability is read from the method's
presence rather than webkitSupportsPresentationMode(), which answers false
until a video track is loaded and would hide the button on preload="none"
feed videos.

There is no iPhone here, so smoke_ios_pip.py reshapes the browser to iOS's
API surface -- deleting the standard entry points and installing WebKit's --
and drives the button through it. That covers what actually broke: which API
the code reaches for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 21:49:16 +00:00
Simon
2abdc31f56 Put the reel itself in the picture-in-picture window, so it scrolls
A video picture-in-picture window renders one <video>'s frames and nothing
else, which is why moving through the reel from it had to be smuggled in
through media keys. Document picture-in-picture opens a real document
instead, so the feed is moved into it: #feed-view and its whole subtree are
appended to the new window's body. Nothing is copied and no <video> is
re-created, so playback continues across the move and the scroll-snap list,
the panes, the split tree and every control keep working -- they are the same
elements, in another window. Scrolling the window scrolls the reel, because
it is the reel.

The lookups in feed.js now go through the cached #feed-view root rather than
document.getElementById, since after the move the feed is no longer in this
document, and the HUD-idle class rides on that root so it travels with it.
The page underneath drops back to the grid while the feed is out, and gets it
back when the window closes.

The video window stays as the fallback: it is Chrome-only, needs a user
gesture, and a hidden tab has none -- so auto-PiP on tab switch is still the
old path. It is suppressed while a document window is open, or the browser
would tear the video out of it on the next switch.

Also fixes the picture-in-picture button, which referenced an undefined
`pane` in bindSharedControls (the parameter was named `slide`) and threw into
a swallowed promise rejection on every click.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 20:45:49 +00:00
Simon
451bf0f983 Pick formats by decode cost, and fix auto picture-in-picture
Two things, both about playing several videos at once.

Capping the resolution per panel wasn't enough, because pixel count isn't
the only cost. A split panel now also prefers a progressive file over HLS
-- every HLS panel runs its own JavaScript demuxer over every segment, so
four panels means four media pipelines doing work a plain MP4 skips
entirely -- and H.264 over AV1 or VP9, which are often decoded in software
and are a cliff rather than a gradient, and 30fps over 60. The height
ceiling still comes first, so cheapness cannot argue a panel into a bigger
picture than it should have, and every format stays reachable as fallback.
The preloaded step's hls.js instances now park after buffering one
fragment and resume when the reader swipes to them, instead of fetching
and demuxing ahead for a step nobody reached.

Auto picture-in-picture had been implemented since the custom player was
written and had never worked. requestPictureInPicture() from a
visibilitychange handler carries no user activation, browsers refuse those,
and .catch(() => {}) swallowed the refusal -- so it failed silently every
time, in the reels feed and the standalone player alike. The declarative
autoPictureInPicture attribute is the form made for this: the browser is
told in advance which video should follow the reader out. The imperative
call stays as a fallback.

With panels there are several candidates and only one window, so binding
every pane made them race for it. The feed picks one deliberately -- the
panel you can hear, or the first if they are all muted -- re-picks when the
step or a mute switch changes, and releases it on close.

Whether a window actually opens is browser policy, not ours: Safari honours
the attribute, Chrome honours it for installed apps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 20:16:08 +00:00
Simon
764e3416a3 Fit the rendition to the panel it plays in
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
2026-09-10 18:47:36 +00:00
Simon
6e5ab68a94 Prepare cards ahead of the scroll, and insert them in batches
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
2026-09-09 19:04:10 +00:00
Simon
e603111d70 Don't fill cards the reader is flinging past
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
2026-09-09 18:47:33 +00:00
Simon
7624ca559a Close three holes in the card release path
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
2026-09-09 15:57:52 +00:00
Simon
49992c1db0 Recycle grid cards instead of rebuilding them
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
2026-09-09 09:47:43 +00:00
Simon
b4031b5d0e Add a grid smoke suite, starting with content bleed
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
2026-09-09 09:12:54 +00:00