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
Streaming a page URL makes /api/stream re-run yt-dlp on every request --
slow, and a 500 on some sites -- so the player and the reels feed now
wait for App.videos.ensureFormats() when an item has no formats
(favorites, or a card clicked before its hover-resolve landed) and play a
real media URL with the extractor's headers, the same path a hovered card
takes. Favorites' download does the same. The page URL survives only as a
last resort when resolution yields nothing.
Two things in the proxy kept this site broken either way:
heavyfetish serves media from paths with a trailing slash
(/get_file/.../11097_720p.mp4/), which missed every extension test in
stream_video and sent even a resolved media URL down the yt-dlp branch --
a full extraction per request, including every seek.
Its CDN (st17.heavyfetish.com) also serves a certificate that expired
2026-02-16, so the upstream fetch failed verification and returned 500.
A browser can't play such a host at all, which is much of why this proxy
exists, so impersonate_get() now retries once without verification, logs
it, and remembers the host so the doomed handshake isn't repeated for
every range request. STREAM_TLS_VERIFY_ONLY=1 restores the hard failure.
Verified in headless Chrome against the real site: a favorite holding
only the page URL now resolves, streams (206, video/mp4, duration
3167.8s, readyState 4, no error) and shows "720p mp4 | 480p mp4" in the
quality menu.
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>
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>