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
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>
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>