Commit Graph

27 Commits

Author SHA1 Message Date
Simon
76a078b9b5 Move through the reel from the picture-in-picture window
A picture-in-picture window shows one video's frames and nothing else --
no markup, no scrolling. What it does have is the controls the page
declares through the Media Session API, so moving through the reel from
the window means its next and previous buttons. Those are the same
controls that appear on a lock screen, on headphones and on media keys,
and with no window open they move the feed itself.

The window rides the pane's own video. That matters for the automatic
case, and it is where the first attempt went wrong: a hidden stand-in
element was used so the window could survive the feed advancing, but a
browser only carries a video out to a window by itself if that video is
already playing, and a hidden one never is. So opening it by hand worked
-- a click is a gesture, and an explicit request needs one -- while
switching tabs could not, and the autoPictureInPicture attribute meant to
handle that case was sitting on a different element than the code used.
Both halves now point at the same video.

Stepping is what a pane's video normally cannot survive, since the feed
destroys a step's videos when it moves on. While a pane is in the window
it is pinned against teardown and its source is swapped in place: the pane
becomes the video being watched, title and poster included, and the feed
is rebuilt around wherever it ended when the window closes. Two panes can
name the same video while that is going on; the rebuild puts the steps
back in order.

The leave handler releases the pin on any leave that finds nothing left in
a window, rather than insisting the element match exactly. The two ways of
being wrong are not the same size: releasing too readily costs a rebuild
nobody sees, while a pin that never releases leaves the feed unable to
recycle that slide for the rest of the session.

Whether a window opens on a tab switch 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:31:32 +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
f0df53365d Split the reels view into panels
A panel can be split to the right or below, and the panel that appears can
be split again, so any arrangement is reachable. The layout is a binary
tree and the leaves, read in order, are the panels of a step.

Scrolling drives all of them: with N panels a step covers N videos and one
swipe advances the whole set. That runs through every index in the feed --
opening on a video, realigning after a rotation, the prefetch buffer, the
render window -- all of which now convert between a video and the step
that holds it.

Each panel has its own sound, so two can play at once if that is what you
want. A new panel inherits the feed-wide setting rather than starting
muted, so a step built later doesn't disagree with what is already on
screen, and the feed-wide button reads as muted only while every panel is.

Two things fall out of panels that are worth knowing. The render window
narrows as panels are added -- five steps ahead of a four-panel split
would be twenty live <video> elements -- so splitting does not multiply
decoding. And splitting rebuilds around the video you are on rather than
keeping it in the panel you split from: steps are aligned to the panel
count, so it stays on screen but not necessarily first.

The per-video helpers were already written against an element holding one
video's controls, so they took a panel unchanged; a slide became the
container that fans out over them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-10 18:23:51 +00:00
Simon
1dbac33359 Recognise a favorite by its URL, and take the listing's copy of it
The same video reaches this client under two identities: saved from a
card it carries the server's id, imported from a Hot Tub backup it
carries only its URL. indexOfEntry already matched on either, but the
grid card and the player asked only whether the key was known -- so an
imported favorite left its own listing card, and the player, showing an
empty heart. All three surfaces now ask one question.

Once matched, the listing's copy is the better one: it has the id the
cards key on, and a thumbnail URL that hasn't been sitting in
localStorage since whenever the backup was taken. So a page of listing
videos rewrites the favorites it matches, keeping only the date each was
first saved -- the one fact the listing doesn't know, and the one the
sort depends on. Nothing is written when nothing differs.

Both identity sets now come from one cache dropped on write, since this
is read once per card built and per layout probe, and each read was
re-parsing the whole favorites list out of localStorage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-08 17:04:34 +00:00
Simon
74b719b2ea Race the CDN and the proxy, for thumbnails and for playback
A thumbnail used to try the provider and only ask /api/image once that
had failed, so every hotlink-blocked host cost a wasted request per card
before anything appeared. Both routes now go out together for the first
thumbnail of a host, and the rest of the batch waits on that one answer
rather than each rediscovering it. Speed decides which image is shown;
capability decides what the host is remembered as, since the proxy tends
to win first contact merely for being same-origin -- pinning a host to it
over that would push a whole page of thumbnails through our own server.

Playback asks the same question, but per video and at play time: one
provider can spread its media over several CDNs, so there is nothing
useful to pre-compute, and the old per-card probe answered for whichever
card happened to scroll past. The direct route is now tested alongside
the proxied playback and takes over if it answers before a frame is
decoded. Whatever loses is cancelled -- the token guards stopped stale
callbacks but left their requests running, so the losing route kept
pulling bytes and the server kept an upstream connection open for them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-08 12:48:18 +00:00
Simon
e2632c962d some more features and fixes (title and show info) 2026-09-07 11:50:45 +00:00
Simon
c54d0889c1 Import favorites from a Hot Tub backup
Settings gains a file picker that reads an exported Hot Tub database and
merges its favorites into this client's. The file never leaves the device:
sqlite.js is a small read-only reader -- header, schema, table b-trees,
record decoding, and the overflow pages that real rows here spill onto --
which is all it takes to walk one table, and avoids putting a wasm SQLite
behind a CDN fetch.

The two sides don't agree on what identifies a video. The app keys one by a
hash it computes locally (a 64-hex string); the server, and so this client,
keys it as something like "reddit-1rdudss". So the merge matches on
normalized URL: entries already saved here are left exactly as they are,
keeping the server id that makes a listing card's heart light up, and only
genuinely new videos are appended.

That means an imported favorite has no server id, so hearts now also match
by URL (`data-fav-url` on the card, feed slide and favorites bar). Without
it an imported favorite would look unsaved on its own card, and clicking
the heart would file a second copy of the same video.

Only the columns a favorite needs are read. `allFormats` is deliberately
left behind: it holds resolved, signed URLs, which is exactly what
favorites must not store (they expire -- see App.favorites.normalize).

Tests (scratchpad): the reader checked against Python's sqlite3 on a real
12MB backup -- table list, every table's row count, all 515 favorites with
their fields and order, and the 25 longest records byte-for-byte, which is
where a wrong overflow split shows up; and the Settings control driven
end-to-end, covering the merge, an existing favorite keeping its id, a
re-import adding nothing, and a listing card recognising an imported
favorite and unfavoriting it cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 08:32:44 +00:00
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
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
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
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
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
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
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
08c96d5903 tiktok feed mode 2026-06-17 16:32:39 +00:00