Commit Graph

76 Commits

Author SHA1 Message Date
Simon
b48d7aa161 Reuse upstream connections, and stop sniffing what we already know
Two costs sat in front of every video: a TLS handshake per upstream request,
and a round trip spent asking the proxy what kind of file it was about to
play.

The session cache was a thread-local, which never once hit -- the server
gives each connection a fresh thread, so every request found empty storage
and built a session, and with it a new connection to the CDN. Instrumented,
that was one session per request; a video is dozens of range requests and an
HLS stream one per segment. Sessions now live in a shared pool, checked out
for a request and returned when its response closes (for a streamed body,
after the last byte), so the connection stays warm. Measured against a
nearby CDN: 32-45ms per request becomes 9-11ms.

The player then HEADed the proxy before playback to sniff a content type --
and that HEAD ran a full upstream GET server-side, so two connections were
opened before the first byte of video was asked for. It now sniffs only when
neither the URL's extension nor yt-dlp's `protocol` says what the source is,
which is nearly never; `protocol` is newly carried through /api/resolve for
exactly this. A HEAD that does still happen asks upstream for one byte and
restates the 206 as a 200 describing the whole resource.

`format_note` joins the resolved fields too: the quality menu has been
reading it since 52d7802, but the backend was dropping it, so no note could
ever have been shown.

Tests (scratchpad, headless): sessions reused across requests, never shared
by two at once, returned after a client aborts mid-stream; HEAD probes one
byte while ranged and plain GETs are byte-identical; no HEAD for an mp4 or a
protocol-bearing URL, and one for a URL with neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 23:27:24 +00:00
Simon
52d7802491 Tick the playing quality, and hide the menu with the HUD
The quality menu now marks the format that is actually on screen when it
opens, read live from the player rather than recorded at bind time, so the
tick follows an automatic pick or a fallback after a failed candidate, not
only a manual choice.

Labels drop the container (mp4 told the viewer nothing about a quality
choice) and gain the extractor's format_note when it says something the
quality doesn't already.

The menu sits outside .cp-hud so it can escape the bar's overflow, which
means the idle fade never reached it -- the player and the reels feed now
close it along with the rest of the HUD. Opening it restarts the idle
countdown (the feed's window is only a second), and on desktop a mouse
resting on the open menu holds the HUD up, same as the bars.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 22:57:04 +00:00
Simon
d508263946 Don't re-resolve a favorite whose URL is already the media file
Some channels hand back the media URL itself as an item's url. Since the
favorites fix, opening one of those sent it to /api/resolve first, so
yt-dlp fetched the media just to report the URL we already had. On a
signed link (`?secure=<ts>-<token>`) that is a second request against
something that may be single-use or IP-bound, and the request that
matters -- the playback fetch -- is then refused. Such URLs now play
directly, with no resolve round trip, as they did before.

Alongside that, three things that make expiry survivable:

/api/stream, after its existing referer-less retry, now retries a 403
completely bare (Range only). Signed CDN links are routinely served to a
plain browser request and refused when it carries extras -- a
`Sec-Fetch-Mode: navigate` on a media subresource, say, which is what
yt-dlp's generic extractor hands back and no real player would send.

When every source fails, the player re-resolves once and retries instead
of giving up, since the likeliest cause is that signed URLs went stale in
a long-open tab rather than the video being gone. A manual quality pick
is dropped for that retry, as it names one of the URLs that just failed.

Favorites stored by older versions still carry a `meta` blob of resolved
formats, long expired; it's now stripped on read so nothing can reach for
one.

Verified: a favorite whose url is a .mp4 plays with zero /api/resolve
calls, straight from that URL; playback, prefetch, feed paging, HUD,
rotation, momentum and the version check all still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 20:41:31 +00:00
Simon
59f7c33ebd Wake the player HUD on mouse movement (desktop)
The fullscreen player only revealed its HUD on a tap, a key press or a
transport action, so on a PC the controls stayed hidden while the mouse
moved over the video -- the reels feed already woke its own HUD on
mousemove, so this was the odd one out.

Any mouse movement over the player now wakes it, and it stays up while
the pointer rests on the top or bottom bar rather than fading out from
under the cursor. Touch and pen are excluded on purpose: taps already
wake the HUD, and a finger dragging for volume or a dismiss swipe isn't
someone looking for the controls. The listeners go on the bars rather
than .cp-hud, which is pointer-events:none so it never eats gestures
over the video.

Verified with synthesized mouse input in headless Chrome: idle 3.2s ->
hidden; move over the video -> shown; still 3.2s -> hidden again; onto
the controls -> shown and still shown after 3.4s resting there; back over
the video and still -> hidden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 17:06:32 +00:00
Simon
acfffb3a91 Prefetch the next page and hold it until the tail rows
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
2026-09-05 16:32:07 +00:00
Simon
6631447acc Have the refresh button check for a new build too
The version poller already diffs /api/version against the manifest
captured at boot -- hot-swapping changed CSS, reloading for changed
JS/HTML once playback allows -- but only on its own 60s tick or when the
tab regains visibility. Pressing refresh now runs that same check in the
background, so a tab left open across a deploy picks the new build up
when the user asks for fresh content rather than up to a minute later.
App.version.checkNow() exposes it; with no baseline (the endpoint was
down at boot) it just adopts the current manifest, since there is nothing
to compare against yet.

Verified against the running app: appending to style.css and pressing
refresh swapped the tag to style.css?v=<hash> with the page still alive,
and appending to a .js file reloaded the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 15:48:11 +00:00
Simon
0f30480af4 Stop the phone zooming in on focus and double-tap
iOS zooms the whole page when you focus a control whose text is under
16px -- and it ignores user-scalable=no, so the font size is the only
lever that actually stops it. The search field (and the settings inputs
and selects) were 14px. They now render at 16px on touch devices only;
the coarse-pointer padding already in that block keeps them the same
physical size, and mouse users keep the 14px look.

body also gets touch-action: manipulation, which drops double-tap-to-zoom
-- the app handles its own taps, and the player/feed set touch-action:
none for their gestures regardless -- plus text-size-adjust: 100% so
Safari stops inflating text on its own after a rotation. Deliberate
pinch-zoom still works; taking that away would hurt anyone who needs it.

Checked in headless Chrome with touch emulation: every input, textarea
and select reports >=16px on a phone, desktop still reports 14px, body
touch-action is manipulation, and the player keeps touch-action: none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 15:26:21 +00:00
Simon
a9893068cd Don't re-anchor the grid on height-only viewport changes
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
2026-09-05 15:15:44 +00:00
Simon
d7086ead27 Play favorites from freshly resolved formats, not the page URL
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
2026-09-05 14:26:15 +00:00
Simon
25dad88ed9 Fix expiring favorites, empty quality menus, rotation scroll jumps
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
2026-09-05 14:07:56 +00:00
Simon
b6b17b1f52 "Open in new tab" 2026-07-06 15:32:31 +00:00
Simon
7207e36510 Replace video player with a fully custom fake-fullscreen HUD
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>
2026-07-01 19:08:21 +00:00
Simon
5d739bec12 adjustable card/font size 2026-06-30 16:26:20 +00:00
Simon
2e6e74b959 bigger video cards 2026-06-30 16:22:30 +00:00
Simon
1d0b435e87 Overlay duration and uploader on card thumbnails
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>
2026-06-30 08:38:54 +00:00
Simon
138c3224de Add modern UI enhancements over the classic redesign
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>
2026-06-30 08:25:37 +00:00
Simon
382a637b95 version updates 2026-06-24 20:54:16 +00:00
Simon
455e5cf8d8 non blocking status loading 2026-06-24 20:48:49 +00:00
Simon
b931765c06 also remove video from the dom 2026-06-24 20:42:37 +00:00
Simon
e3eeaacc53 skip broken video on tintok 2026-06-24 20:39:03 +00:00
Simon
48a15759fc video end setting 2026-06-24 20:19:28 +00:00
Simon
9fe7511b4d header bugfix 2026-06-24 20:08:08 +00:00
Simon
17f3161d55 fixed bug 2026-06-23 21:55:53 +00:00
Simon
5aa95e90d4 rotation edge case 2026-06-23 21:42:29 +00:00
Simon
f5bb33521e improve tiktok mode 2026-06-23 21:19:19 +00:00
Simon
b9ff61244c virtual video item cards 2026-06-23 20:05:59 +00:00
Simon
55828c9726 changed style and increased performance 2026-06-23 19:38:59 +00:00
Simon
d6865d7c35 advanced probing 2026-06-23 12:44:13 +00:00
Simon
80476a8a42 log probing 2026-06-23 08:09:26 +00:00
Simon
785b991d01 probe videos in background 2026-06-23 07:54:35 +00:00
Simon
a251b274db fallback of formats 2026-06-23 06:59:40 +00:00
Simon
bec981a262 deselect a channel on "all <channel-group>" 2026-06-23 06:46:06 +00:00
Simon
b5b3e13dd0 live stream support 2026-06-22 12:34:47 +00:00
Simon
a97f7e7b0f dynamic video loading 2026-06-19 22:03:49 +00:00
Simon
6b36b97bd1 play currently focused video in tiktok mode 2026-06-18 13:42:51 +00:00
Simon
988e11b159 fix missing headers in video get requests 2026-06-18 11:55:08 +00:00
Simon
1b6fa5f924 channel groups 2026-06-18 11:19:15 +00:00
Simon
08c96d5903 tiktok feed mode 2026-06-17 16:32:39 +00:00
Simon
f8072884b2 preferred quality setting 2026-06-17 15:44:20 +00:00
Simon
7ba8896405 fix player bug with apple mpegurl 2026-02-12 08:23:27 +00:00
Simon
ece4852d4f searchbar X Button visibility fix 2026-02-11 15:23:45 +00:00
Simon
a06a952a28 timestamp fix 2026-02-11 15:21:02 +00:00
Simon
24a2c9f738 tags and title fix 2026-02-11 15:16:23 +00:00
Simon
1f5910a996 added loading indicator 2026-02-11 07:04:59 +00:00
Simon
81597c3bb2 correct video element order 2026-02-09 22:08:28 +00:00
Simon
3d81b6aae7 clear search button 2026-02-09 21:40:34 +00:00
Simon
d2e1e3adea honoring formats and http headers 2026-02-09 19:16:44 +00:00
Simon
7b90c05a29 load image fallback 2026-02-09 16:28:01 +00:00
Simon
16e42cf318 visual improvements and bugfixes 2026-02-09 16:09:42 +00:00
Simon
e6d36711b1 Download functionality 2026-02-09 14:50:17 +00:00