A page used to be requested at the moment the reader hit the bottom, so
the cards that appeared were empty frames filling in as their thumbnails
arrived. Now the next page is fetched as soon as the current one renders
and its thumbnails are decoded off-screen (low priority, started on an
idle callback, so warming never competes with what's on screen), then
held until the second-to-last row comes into view -- at which point the
cards appear already finished, and the page after that starts loading.
The two loaders are split into fetch-a-batch and commit-a-batch so the
prefetcher and the on-demand path share them; a held batch is dropped
when the result set changes (search, channel, filters).
Three things this surfaced, all handled:
loadVideos awaited the in-flight prefetch before raising state.isLoading,
so every caller that arrived meanwhile sailed past the guard and started
a duplicate page load, each one re-filling the viewport and calling back
in. On a short desktop page that amplified until the tab stopped
responding. A guard now covers the whole call.
The reveal test can't be "the topmost visible card is in the last two
rows": a desktop viewport shows several rows at once, so the reader would
reach the end of the list without it ever passing. It's now "the
second-to-last row has come into view", measured against the layout.
The reels feed pulls pages through the same entry point, where the grid's
scroll position means nothing -- it (and the Load more button) now pass
force, which skips the hold.
Verified in headless Chrome: page 2 fetched and all 12 of its thumbnails
warmed while it was still hidden, grid still at 12 cards; revealed on the
second-to-last row (phone: viewport bottom 4337px vs row at 4335px;
desktop 4-col: 1704px vs 1687px) with its images already decoded; feed
paging, search reset, rotation, momentum and playback all still good.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
The settle window I added for rotation also ran for every `resize`, and
on a phone the URL bar collapsing during a fast flick is exactly that.
It re-asserted an anchor captured before the flick, so a scrollTo landed
mid-momentum and stopped the scroll dead.
Only a width change (or an orientationchange) can move a card: positions
are absolute pixels in the grid's own space, so a height-only resize
leaves both the layout and the scroll position correct and needs no
restore at all. The one exception kept is a scrollbar appearing on
desktop, which changes the grid's inner width while window.innerWidth
stays put -- that re-packs, but only when the columns actually moved.
Modelled the failure in headless Chrome (height change mid-flick, scroll
continuing): the flick was yanked back 173px before, and now runs on to
where it was headed. Rotation still re-anchors, and a fast scroll with 80
videos loaded holds 60fps (median 16.7ms/frame, max 17.2ms, no frame
over 32ms).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
Favorites persisted the *resolved* stream metadata (resolveAndProbe
mutates video.meta with yt-dlp's CDN format URLs), so a favorite opened
the next day replayed a dead link. They now store only the page URL and
identifying fields, and ignore any stale meta left in localStorage --
playback, download and info re-resolve through the backend, which
resolves a page URL live in /api/stream.
That left the quality switcher empty for anything not yet resolved
(favorites, cards clicked before their hover-resolve landed, feed
slides), so App.videos.ensureFormats now resolves formats once per
session -- cached by video id rather than per object, so any object
describing the same video gets them -- and both the player and the reels
feed rebuild their format menu when they arrive. Playback isn't blocked:
it already starts from the page URL via the proxy.
Rotating a phone also jumped the grid to a completely different place:
the anchor was read inside the resize handler (by which point the
browser has already moved the scroll) and asserted once, and every
re-pack discarded known card heights for the 16:9 placeholder estimate.
The virtualizer now tracks the anchor on every scroll pass, re-asserts
it across a short settling window (ending early on a real gesture), and
remembers each thumbnail's true aspect ratio so a re-pack places cards
at their real heights. Verified in headless Chrome across a
portrait/landscape/portrait cycle: visible videos 37-39 -> 36-40 ->
36-38, against 37-39 -> 34-37 -> 29-30 before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
Single fullscreen player state everywhere (desktop/Android/iOS/feed) instead
of the old modal-vs-native-fullscreen split, with custom controls: draggable
timeline with buffered range, dynamically-escalating skip buttons (double-tap
zones too), per-video format switching, favorites, PiP with auto-PiP on
backgrounding, volume swipe, TikTok-style HUD auto-hide, and swipe-down/
back-button/close-button dismissal. Reels feed reuses the same skip/format/
PiP logic via the new customPlayer.js shared module.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Move the duration (bottom-right) and uploader (bottom-left) onto the
thumbnail as dark-transparent pills instead of text rows below it, for
both .video-card and .favorite-card. Wrap the thumbnail in .video-thumb
to anchor the overlays; uploader stays clickable and truncates with
ellipsis. Favorite cards now show duration too (was stored, never shown).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Layered progressive polish on the warm classic + brass theme:
- Card entrance animation on first mount (virtualizer-aware)
- Cursor-tracking brass spotlight border on cards
- Thumbnail skeleton shimmer until the poster paints
- Hover video preview after a short dwell (only when formats resolved)
- View Transition + blurred-poster ambient backdrop on player open
- Favorite heart pop + expanding ring on add
- ⌘K command palette (search, theme, density, reels, source/channel)
- Scroll-progress bar + back-to-top FAB
- Grid density toggle (comfortable/compact)
- Reels HUD: serif title, brass scrubber, muted-state pulse
All new motion respects prefers-reduced-motion; no JS/HTML structure
changes to the core grid/feed. New glue lives in frontend/js/enhance.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>