Compare commits

..

20 Commits

Author SHA1 Message Date
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
0009574b77 Keep favorites reachable with the bar hidden
The favorites bar carries the "Browse all" button and the sort control, so
switching the bar off in settings hid the way in to both. The command
palette now offers "Browse favorites" (or "Back to videos") and each sort
order, and opening the grid re-renders the bar so its header -- the way back
out -- is mounted even when settings say hidden.

Tests (scratchpad): a new palette test that starts with the bar switched
off, opens the grid through the palette, re-sorts through it, and returns to
the listing. It caught the second half of this: opening from the palette did
not re-render the bar, leaving no visible way out.

Also fixes the favorites playback test, which had been wedging headless
Chrome all session. It was reloading by navigating to the URL already
loaded; the first evaluate after that is answered by the outgoing execution
context and every one after it hangs forever. It now does its second visit
in a fresh tab -- localStorage is shared per origin, so it models "next day"
the same way -- and asserts what it had only been printing. It also runs
hermetically now (no favorites left over from another test, CDN icons and
fonts blocked) and no longer runs its whole body on import, which is what
made it hijack a debugging session earlier.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 11:34:45 +00:00
Simon
d4ed9dce5d Browse favorites like a listing, sorted, and page them as you scroll
Favorites now carry `favoriteDate`. Ones saved before this had no way of
knowing when they were saved, so they are all stamped with the moment the
client first reads them -- they sort together as one batch, at the point
favorites learned to keep dates. An import brings the date the other client
recorded instead, so a restored library keeps its history.

The bar used to build a card per favorite, which an import of several
hundred made an expensive way to open the app. It now renders a screenful
and appends more as the strip is scrolled.

"Browse all" turns the whole grid into favorites: the same cards, the same
virtualized masonry, the same infinite scroll and reels mode as a channel
listing -- App.videos.loadVideos simply pages out of localStorage instead of
the server while that view is open. Sort applies to the bar and the grid
together: recently added (default), oldest, title, longest, shortest, and a
shuffle for rediscovering a long list.

Tests (scratchpad): dates backfilled onto undated favorites, the bar paging
as it scrolls rather than building every card, the grid paging to the full
list with zero server calls, each sort order reordering it, and the way back
to the channel listing. Also measured: 515 imported favorites load with the
page responsive and 24 bar cards built.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-06 10:46:47 +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
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
0f7e27fd77 Stamp asset URLs with their content hash
index.html is revalidated on every load, but the assets it names are not
under our control once they leave the origin: Cloudflare rewrites our
`Cache-Control: no-cache` on /static/* to `max-age=14400`, so a phone --
an iOS home-screen app above all, which keeps running whatever it has --
can execute four-hour-old JavaScript after a deploy.

The URLs now carry the file's content hash (static/js/main.js?v=<hash>),
reusing the manifest /api/version already computes, so every deploy asks
for URLs no cache can answer from an old copy. index.html itself gets an
explicit no-cache, must-revalidate.

Verified: served HTML carries per-file hashes, a changed file yields a
new URL, the app boots clean, and the refresh button's update path still
hot-swaps CSS and reloads for JS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 16:02:11 +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
19 changed files with 4107 additions and 723 deletions

View File

@@ -10,6 +10,7 @@ import yt_dlp
from yt_dlp.networking.impersonate import ImpersonateTarget from yt_dlp.networking.impersonate import ImpersonateTarget
from curl_cffi import requests as impersonate_requests from curl_cffi import requests as impersonate_requests
import threading import threading
import queue
import io import io
import time import time
import hashlib import hashlib
@@ -20,23 +21,125 @@ from urllib.parse import urljoin
# that isn't a real browser, so impersonation must be on by default. # that isn't a real browser, so impersonation must be on by default.
IMPERSONATE_TARGET = os.getenv('STREAM_IMPERSONATE', 'chrome').strip() or 'chrome' IMPERSONATE_TARGET = os.getenv('STREAM_IMPERSONATE', 'chrome').strip() or 'chrome'
# curl_cffi sessions wrap a single libcurl handle and are not safe to share # curl_cffi sessions wrap a single libcurl handle: they can't be shared by two
# across threads; keep one per worker thread so the Flask `threaded=True` # requests at once, but reusing one *across* requests is what keeps the upstream
# server can proxy concurrent segments without corrupting state. # connection alive, and with it the TLS handshake we already paid for. A video
_thread_local = threading.local() # arrives as dozens of range requests (and an HLS stream as one request per
# segment), so a handshake per request is the difference between a stall and a
# seek.
#
# This used to be a thread-local, which never actually hit: the development
# server gives every connection a brand-new thread, so each request found empty
# thread-local storage and built a session from scratch. Sessions live in a
# shared pool instead -- checked out for the duration of one request, returned
# when its response is closed (which, for a streamed body, is when the last byte
# has been sent). LIFO so the hottest connection is the one handed out next.
try:
_SESSION_POOL_SIZE = max(1, int(os.getenv('STREAM_SESSION_POOL', '') or 8))
except ValueError:
_SESSION_POOL_SIZE = 8
_session_pool = queue.LifoQueue(maxsize=_SESSION_POOL_SIZE)
def get_impersonate_session(): def _borrow_session():
sess = getattr(_thread_local, 'session', None) """A session nobody else is using: from the pool, or a fresh one."""
if sess is None: try:
sess = impersonate_requests.Session(impersonate=IMPERSONATE_TARGET) return _session_pool.get_nowait()
_thread_local.session = sess except queue.Empty:
return sess return impersonate_requests.Session(impersonate=IMPERSONATE_TARGET)
# Stream params that have dedicated meaning and must never be treated as headers.
def _return_session(sess):
"""Hand a session back. Beyond the pool's size the extras are closed, so a
burst of concurrency doesn't leave idle connections open forever."""
try:
_session_pool.put_nowait(sess)
except queue.Full:
try:
sess.close()
except Exception:
pass
def _discard_session(sess):
"""Drop a session that raised, rather than pooling a possibly-poisoned handle."""
try:
sess.close()
except Exception:
pass
def _release_when_closed(resp, sess):
"""Return `sess` to the pool once `resp` is closed.
Every caller either closes the response outright or streams it through a
generator that closes in a `finally`, so this is where a request's exclusive
hold on a session ends. Idempotent: a double close must not put the same
session in the pool twice."""
original_close = resp.close
released = False
def close():
nonlocal released
try:
original_close()
finally:
if not released:
released = True
_return_session(sess)
resp.close = close
return resp
def _is_tls_verify_error(err):
message = str(err).lower()
return 'certificate' in message or 'curl: (60)' in message or 'ssl: ' in message
# Hosts already proven to fail certificate verification. A video is fetched in
# many range requests, so remembering the host keeps us from paying for a
# doomed TLS handshake on every one of them.
_tls_unverified_hosts = set()
def impersonate_get(url, **kwargs):
"""Upstream GET that survives an origin with a broken certificate.
Some media hosts serve expired certs (heavyfetish's stNN CDN, for one), which
a browser refuses outright -- part of why this proxy exists. The viewer's
connection to *us* stays verified either way, so rather than failing the
stream we retry once with verification off, and say so in the log. Set
STREAM_TLS_VERIFY_ONLY=1 to keep the hard failure instead."""
host = urllib.parse.urlparse(url).netloc
sess = _borrow_session()
if host in _tls_unverified_hosts:
try:
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
except Exception:
_discard_session(sess)
raise
try:
return _release_when_closed(sess.get(url, **kwargs), sess)
except Exception as err:
strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
if strict or not _is_tls_verify_error(err):
_discard_session(sess)
raise
app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err)
_tls_unverified_hosts.add(host)
try:
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
except Exception:
_discard_session(sess)
raise
# Request params that have dedicated meaning and must never be treated as headers.
# `referer` is mapped to a real Referer header by collect_passthrough_headers, but # `referer` is mapped to a real Referer header by collect_passthrough_headers, but
# `live` is purely a playback hint and must not leak upstream as a header. # `live` is purely a playback hint and must not leak upstream as a header. `full`
STREAM_RESERVED_PARAMS = {'url', 'live'} # is /api/resolve's "give me everything" switch and is likewise ours, not the
# origin's.
STREAM_RESERVED_PARAMS = {'url', 'live', 'full'}
# Headers that affect the transport layer rather than the resource itself; allowing # Headers that affect the transport layer rather than the resource itself; allowing
# these to be forwarded could enable request smuggling or vhost-routing abuse. # these to be forwarded could enable request smuggling or vhost-routing abuse.
STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'} STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'}
@@ -47,6 +150,10 @@ STREAM_IMPERSONATION_MANAGED_HEADERS = {
'user-agent', 'accept', 'accept-encoding', 'accept-language', 'user-agent', 'accept', 'accept-encoding', 'accept-language',
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform',
} }
# `Content-Range: bytes 0-0/12345` -> the total size of the resource. A '*'
# total (an origin that won't say) deliberately doesn't match, so the length is
# then simply left out rather than guessed at.
_CONTENT_RANGE_TOTAL_RE = re.compile(r'^\s*bytes\s+\d+-\d+/(\d+)\s*$', re.I)
# RFC 7230 token charset for header field-names. # RFC 7230 token charset for header field-names.
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$") HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
# Reject control characters (CR/LF/NUL etc.) that could be used for header injection. # Reject control characters (CR/LF/NUL etc.) that could be used for header injection.
@@ -184,8 +291,31 @@ _resolve_cache_lock = threading.Lock()
# Per-format fields the frontend needs to rank formats and build stream/probe # Per-format fields the frontend needs to rank formats and build stream/probe
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a # URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
# yt-dlp format dict is dropped to keep the payload small. # yt-dlp format dict is dropped to keep the payload small.
# `protocol` is what yt-dlp calls the delivery method ('https', 'm3u8_native',
# 'http_dash_segments', ...). Passing it on saves the player a HEAD round trip
# against the proxy -- and with it a whole upstream connection -- for URLs whose
# extension doesn't say what they are, which is most signed CDN links.
_RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr', _RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality') 'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality',
'protocol', 'format_note')
def _trim_resolve_info(info):
"""The lean payload playback needs: the media URLs, the headers that make
them work, and just enough per-format detail to rank them. This is what
every hovered card asks for, so it stays small."""
formats = []
for fmt in ((info.get('formats') if info else None) or []):
if not fmt.get('url'):
continue
formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None})
return {
'url': info.get('url') if info else None,
'http_headers': (info.get('http_headers') if info else None) or {},
'isLive': bool(info.get('is_live')) if info else False,
'formats': formats,
}
# Some channels surface pages that yt-dlp can't extract because the video is # Some channels surface pages that yt-dlp can't extract because the video is
# embedded in a third-party JS player iframe (e.g. the xtremestream family used # embedded in a third-party JS player iframe (e.g. the xtremestream family used
@@ -200,8 +330,11 @@ def resolve_unsupported_embed(page_url):
"""Best-effort resolver for iframe-embedded JS players yt-dlp can't handle. """Best-effort resolver for iframe-embedded JS players yt-dlp can't handle.
Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose
single format is the embed's HLS playlist, or None if nothing was found.""" single format is the embed's HLS playlist, or None if nothing was found."""
# Both fetches are small and fully buffered, so this holds one pooled session
# for the whole scrape rather than going through impersonate_get (whose
# release is tied to closing a streamed response).
sess = _borrow_session()
try: try:
sess = get_impersonate_session()
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15) page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
embed_url = None embed_url = None
for src in _EMBED_IFRAME_RE.findall(page.text): for src in _EMBED_IFRAME_RE.findall(page.text):
@@ -229,14 +362,25 @@ def resolve_unsupported_embed(page_url):
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}], 'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
} }
except Exception: except Exception:
_discard_session(sess)
sess = None
return None return None
finally:
if sess is not None:
_return_session(sess)
@app.route('/api/resolve', methods=['POST', 'GET']) @app.route('/api/resolve', methods=['POST', 'GET'])
def resolve_video(): def resolve_video():
"""Resolve a page URL to its playable formats via yt-dlp and return them as """Resolve a page URL to its playable formats via yt-dlp and return them as
JSON. The frontend calls this on demand (when a card is hovered or scrolled JSON. The frontend calls this on demand (when a card is hovered or scrolled
into view) to learn the real media URLs so it can background-probe them for into view) to learn the real media URLs so it can background-probe them for
direct, proxy-free playability.""" direct, proxy-free playability.
`full=1` returns the extractor's whole info dict instead of the trimmed
playback payload -- everything it knows about the video (description, dates,
counts, tags, thumbnails, every format field), which is what the Show info
panel exists to display. Both views come from one extraction and one cache
entry, so asking for the full one costs no extra work upstream."""
if request.method == 'POST': if request.method == 'POST':
source = request.json or {} source = request.json or {}
video_url = source.get('url') video_url = source.get('url')
@@ -247,11 +391,19 @@ def resolve_video():
if not video_url: if not video_url:
return jsonify({"error": "No URL provided"}), 400 return jsonify({"error": "No URL provided"}), 400
want_full = str(source.get('full', '')).strip().lower() in ('1', 'true', 'yes', 'on')
def view_of(info):
if not want_full:
return _trim_resolve_info(info)
# Nothing to show, but answer in the same shape rather than `null`.
return info if info else {}
now = time.time() now = time.time()
with _resolve_cache_lock: with _resolve_cache_lock:
cached = _resolve_cache.get(video_url) cached = _resolve_cache.get(video_url)
if cached and cached[0] > now: if cached and cached[0] > now:
return jsonify(cached[1]) return jsonify(view_of(cached[1]))
ydl_opts = { ydl_opts = {
'quiet': True, 'quiet': True,
@@ -268,6 +420,10 @@ def resolve_video():
try: try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(video_url, download=False) info = ydl.extract_info(video_url, download=False)
# The raw info dict holds objects that don't survive JSON (and
# internal `__`-prefixed bookkeeping). This is the same pass yt-dlp
# itself runs behind --dump-json.
info = ydl.sanitize_info(info, remove_private_keys=True)
except Exception as e: except Exception as e:
# Many channels point at sites yt-dlp can't extract ("Unsupported URL"). # Many channels point at sites yt-dlp can't extract ("Unsupported URL").
# That's not fatal here -- the embed fallback below may still find a # That's not fatal here -- the embed fallback below may still find a
@@ -282,26 +438,17 @@ def resolve_video():
if embed: if embed:
info = embed info = embed
formats = [] # The extraction is cached whole, and each caller is served the view it
for fmt in ((info.get('formats') if info else None) or []): # asked for. A failed extraction (info is None) is cached the same way, so a
if not fmt.get('url'): # video that can't be resolved is attempted once per TTL rather than on
continue # every hover.
formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None})
result = {
'url': info.get('url') if info else None,
'http_headers': (info.get('http_headers') if info else None) or {},
'isLive': bool(info.get('is_live')) if info else False,
'formats': formats,
}
with _resolve_cache_lock: with _resolve_cache_lock:
# Drop expired entries so the cache doesn't grow without bound. # Drop expired entries so the cache doesn't grow without bound.
for key in [k for k, v in _resolve_cache.items() if v[0] <= now]: for key in [k for k, v in _resolve_cache.items() if v[0] <= now]:
_resolve_cache.pop(key, None) _resolve_cache.pop(key, None)
_resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, result) _resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, info)
return jsonify(result) return jsonify(view_of(info))
@app.route('/api/image', methods=['GET', 'HEAD']) @app.route('/api/image', methods=['GET', 'HEAD'])
def image_proxy(): def image_proxy():
@@ -348,10 +495,38 @@ def image_proxy():
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
# Captures the path *relative to the frontend dir* (the manifest's key), since
# the served URL carries an extra `static/` prefix.
_ASSET_REF_RE = re.compile(r'(src|href)="static/((?:js|css)/[^"?#]+)"')
@app.route('/') @app.route('/')
def index(): def index():
"""Serve index.html with each local asset URL stamped with its content hash.
index.html itself is always revalidated, but the assets it names are not
under our control once a CDN or a phone has them: Cloudflare rewrites our
`no-cache` to `max-age=14400`, and an iOS home-screen app will happily run
four-hour-old JavaScript. A content hash in the query gives every deploy new
URLs, which no cache can satisfy from an old copy -- so a reload always
lands on the build that's actually deployed."""
hashes = _version_payload().get('files', {})
def stamp(match):
attr, rel = match.group(1), match.group(2)
digest = hashes.get(rel)
return f'{attr}="static/{rel}?v={digest}"' if digest else match.group(0)
try:
with open(os.path.join(_FRONTEND_DIR, 'index.html'), encoding='utf-8') as fh:
html = _ASSET_REF_RE.sub(stamp, fh.read())
except OSError:
return send_from_directory(app.static_folder, 'index.html') return send_from_directory(app.static_folder, 'index.html')
resp = Response(html, mimetype='text/html')
resp.headers['Cache-Control'] = 'no-cache, must-revalidate'
return resp
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():
return send_from_directory(app.static_folder, 'favicon.ico') return send_from_directory(app.static_folder, 'favicon.ico')
@@ -395,8 +570,7 @@ def _compute_version_payload(files):
return {'version': combined.hexdigest(), 'files': file_hashes} return {'version': combined.hexdigest(), 'files': file_hashes}
@app.route('/api/version', methods=['GET']) def _version_payload():
def frontend_version():
files = _scan_frontend_files() files = _scan_frontend_files()
# Use the newest mtime across tracked files as a cheap cache key so frequent # Use the newest mtime across tracked files as a cheap cache key so frequent
# polls only re-hash contents when something on disk actually changed. # polls only re-hash contents when something on disk actually changed.
@@ -408,8 +582,12 @@ def frontend_version():
if _version_cache['mtime'] != latest_mtime or _version_cache['payload'] is None: if _version_cache['mtime'] != latest_mtime or _version_cache['payload'] is None:
_version_cache['payload'] = _compute_version_payload(files) _version_cache['payload'] = _compute_version_payload(files)
_version_cache['mtime'] = latest_mtime _version_cache['mtime'] = latest_mtime
payload = _version_cache['payload'] return _version_cache['payload']
resp = jsonify(payload)
@app.route('/api/version', methods=['GET'])
def frontend_version():
resp = jsonify(_version_payload())
resp.headers['Cache-Control'] = 'no-store' resp.headers['Cache-Control'] = 'no-store'
return resp return resp
@@ -438,14 +616,21 @@ def stream_video():
dbg(f"method={request.method} url={video_url} live={live_hint}") dbg(f"method={request.method} url={video_url} live={live_hint}")
def media_path(url):
# Some sites serve media from a path with a trailing slash
# (heavyfetish: /get_file/.../11097_720p.mp4/). Without stripping it,
# every extension test below misses and the URL takes the yt-dlp branch
# instead -- a full extraction per request, including every seek.
return urllib.parse.urlparse(url).path.lower().rstrip('/')
def is_hls(url): def is_hls(url):
return '.m3u8' in urllib.parse.urlparse(url).path return '.m3u8' in media_path(url)
def is_dash(url): def is_dash(url):
return urllib.parse.urlparse(url).path.lower().endswith('.mpd') return media_path(url).endswith('.mpd')
def guess_content_type(url): def guess_content_type(url):
path = urllib.parse.urlparse(url).path.lower() path = media_path(url)
if path.endswith('.m3u8'): if path.endswith('.m3u8'):
return 'application/vnd.apple.mpegurl' return 'application/vnd.apple.mpegurl'
if path.endswith('.mpd'): if path.endswith('.mpd'):
@@ -467,7 +652,7 @@ def stream_video():
return None return None
def is_direct_media(url): def is_direct_media(url):
path = urllib.parse.urlparse(url).path.lower() path = media_path(url)
return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov')) return any(path.endswith(ext) for ext in ('.mp4', '.m4v', '.m4s', '.ts', '.webm', '.mov'))
def looks_like_m3u8_bytes(chunk): def looks_like_m3u8_bytes(chunk):
@@ -563,7 +748,16 @@ def stream_video():
if 'Range' in request.headers: if 'Range' in request.headers:
safe_request_headers['Range'] = request.headers['Range'] safe_request_headers['Range'] = request.headers['Range']
resp = get_impersonate_session().get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True) # A HEAD wants headers, not video -- but we don't send a HEAD upstream
# here (hotlink-protected origins routinely answer one method and not
# the other, and the GET is the one we know works). Ask for a single
# byte instead: same headers, none of the transfer. The response is
# restated as a description of the whole resource further down.
head_probe = request.method == 'HEAD' and 'Range' not in safe_request_headers
if head_probe:
safe_request_headers['Range'] = 'bytes=0-0'
resp = impersonate_get(target_url, headers=safe_request_headers, stream=True, timeout=30, allow_redirects=True)
# Some channel proxies (e.g. the "animeidhentai" hottub proxy) use # Some channel proxies (e.g. the "animeidhentai" hottub proxy) use
# inverted hotlink protection: they 403 any request that carries a # inverted hotlink protection: they 403 any request that carries a
# Referer/Origin and only serve referer-less ones. Other CDNs require # Referer/Origin and only serve referer-less ones. Other CDNs require
@@ -572,7 +766,20 @@ def stream_video():
dbg("upstream 403 with referer; retrying without referer/origin") dbg("upstream 403 with referer; retrying without referer/origin")
resp.close() resp.close()
referer_less = {k: v for k, v in safe_request_headers.items() if k not in ('Referer', 'Origin')} referer_less = {k: v for k, v in safe_request_headers.items() if k not in ('Referer', 'Origin')}
resp = get_impersonate_session().get(target_url, headers=referer_less, stream=True, timeout=30, allow_redirects=True) resp = impersonate_get(target_url, headers=referer_less, stream=True, timeout=30, allow_redirects=True)
# Still refused: strip everything the extractor asked us to relay and go
# in bare (Range only, plus whatever impersonation supplies). Signed CDN
# links are often served fine to a plain browser request and refused when
# it carries extras -- a `Sec-Fetch-Mode: navigate` on a media
# subresource, say, which is exactly what yt-dlp's generic extractor
# hands back and what a real player would never send.
if resp.status_code == 403 and len(safe_request_headers) > (1 if 'Range' in safe_request_headers else 0):
dbg("upstream still 403; retrying bare (range only)")
resp.close()
bare = {}
if 'Range' in safe_request_headers:
bare['Range'] = safe_request_headers['Range']
resp = impersonate_get(target_url, headers=bare, stream=True, timeout=30, allow_redirects=True)
if debug_enabled: if debug_enabled:
dbg(f"upstream status={resp.status_code} content_type={resp.headers.get('Content-Type')} content_length={resp.headers.get('Content-Length')}") dbg(f"upstream status={resp.status_code} content_type={resp.headers.get('Content-Type')} content_length={resp.headers.get('Content-Length')}")
@@ -610,8 +817,32 @@ def stream_video():
) )
if request.method == 'HEAD': if request.method == 'HEAD':
status = resp.status_code
# Read from the headers we already copied: curl_cffi doesn't keep a
# response's headers readable once it has been closed.
content_range = next((value for name, value in forwarded_headers
if name.lower() == 'content-range'), '')
resp.close() resp.close()
return Response("", status=resp.status_code, headers=forwarded_headers) if head_probe and status == 206:
# We asked for one byte; the caller asked about the resource.
# Restate the 206 as a 200 describing the whole thing, taking
# the real length out of `Content-Range: bytes 0-0/<total>`.
# (An origin that ignored the range answered 200 already, and
# its headers need no fixing.)
match = _CONTENT_RANGE_TOTAL_RE.match(content_range or '')
total = match.group(1) if match else None
forwarded_headers = [(name, value) for name, value in forwarded_headers
if name.lower() not in ('content-range', 'content-length')]
head_response = Response("", status=200, headers=forwarded_headers)
if total:
# A HEAD carries the entity headers its GET would, with no
# body -- so the length is the resource's, not the zero
# bytes we're sending. Werkzeug derives Content-Length from
# the body unless told not to.
head_response.automatically_set_content_length = False
head_response.headers['Content-Length'] = total
return head_response
return Response("", status=status, headers=forwarded_headers)
def generate(): def generate():
try: try:
@@ -685,14 +916,14 @@ def stream_video():
for key, value in upstream_headers.items(): for key, value in upstream_headers.items():
if value: if value:
headers[key] = value headers[key] = value
resp = get_impersonate_session().get(playlist_url, headers=headers, stream=True, timeout=30) resp = impersonate_get(playlist_url, headers=headers, stream=True, timeout=30)
# See proxy_response: retry without referer for inverted hotlink # See proxy_response: retry without referer for inverted hotlink
# protection that 403s any refered request. # protection that 403s any refered request.
if resp.status_code == 403 and ('Referer' in headers or 'Origin' in headers): if resp.status_code == 403 and ('Referer' in headers or 'Origin' in headers):
dbg("playlist upstream 403 with referer; retrying without referer/origin") dbg("playlist upstream 403 with referer; retrying without referer/origin")
resp.close() resp.close()
referer_less = {k: v for k, v in headers.items() if k not in ('Referer', 'Origin')} referer_less = {k: v for k, v in headers.items() if k not in ('Referer', 'Origin')}
resp = get_impersonate_session().get(playlist_url, headers=referer_less, stream=True, timeout=30) resp = impersonate_get(playlist_url, headers=referer_less, stream=True, timeout=30)
base_url = resp.url base_url = resp.url
if resp.status_code >= 400: if resp.status_code >= 400:

View File

@@ -41,6 +41,13 @@ body {
line-height: 1.5; line-height: 1.5;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
/* No accidental zoom: `manipulation` drops double-tap-to-zoom (the app's own
taps are handled in JS anyway) while leaving panning and deliberate
pinch-zoom alone, and text-size-adjust stops Safari inflating text of its
own accord after a rotation. */
touch-action: manipulation;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
} }
body.drawer-open { body.drawer-open {
@@ -391,6 +398,15 @@ body.theme-light .sidebar {
margin-bottom: 8px; margin-bottom: 8px;
} }
/* Explanatory line under a control -- and where the import reports back. Not a
`label`, so it keeps sentence case and normal letter spacing. */
.setting-note {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.setting-label-row label { .setting-label-row label {
margin-bottom: 0; margin-bottom: 0;
} }
@@ -559,6 +575,38 @@ body.theme-light .input-row input:focus {
background: var(--bg-tertiary); background: var(--bg-tertiary);
} }
.setting-item input[type="range"] {
width: 100%;
appearance: none;
-webkit-appearance: none;
height: 6px;
border-radius: 999px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
cursor: pointer;
margin: 6px 0;
}
.setting-item input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-primary);
border: none;
cursor: pointer;
}
.setting-item input[type="range"]::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-primary);
border: none;
cursor: pointer;
}
.setting-item select { .setting-item select {
width: 100%; width: 100%;
padding: 9px 12px; padding: 9px 12px;
@@ -727,6 +775,35 @@ body.theme-light .setting-item select option {
font-family: var(--font-display); font-family: var(--font-display);
} }
.favorites-actions {
display: flex;
align-items: center;
gap: 8px;
}
.favorites-sort {
height: 28px;
padding: 0 8px;
border-radius: var(--radius-sm, 6px);
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
font-size: 12px;
}
.favorites-actions .btn-secondary {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
/* Browsing favorites as a grid: the bar above it would be the same list twice,
so it collapses to its header (which carries the way back out). */
body.favorites-view-open .favorites-list,
body.favorites-view-open .favorites-empty {
display: none;
}
.favorites-list { .favorites-list {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -780,6 +857,8 @@ body.theme-light .setting-item select option {
padding: 10px 12px 12px 12px; padding: 10px 12px 12px 12px;
} }
/* One line, always. A title too long for the card scrolls across it (see
App.marquee) rather than wrapping the card to an uneven height. */
.favorite-info h4 { .favorite-info h4 {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@@ -787,6 +866,20 @@ body.theme-light .setting-item select option {
color: var(--text-primary); color: var(--text-primary);
font-family: var(--font-display); font-family: var(--font-display);
line-height: 1.3; line-height: 1.3;
white-space: nowrap;
overflow: hidden;
position: relative;
}
.favorite-title-text {
display: inline-block;
padding-right: 24px;
transform: translateX(0);
}
.favorite-card.is-title-active .favorite-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform;
} }
.favorites-empty { .favorites-empty {
@@ -798,7 +891,7 @@ body.theme-light .setting-item select option {
/* Grid Container */ /* Grid Container */
.grid-container { .grid-container {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-auto-rows: 10px; grid-auto-rows: 10px;
gap: 16px; gap: 16px;
align-items: start; align-items: start;
@@ -840,6 +933,12 @@ body.theme-light .setting-item select option {
} }
@media (max-width: 480px) { @media (max-width: 480px) {
.grid-container {
/* One full-width card per row on phones. */
grid-template-columns: 1fr;
gap: 16px;
}
.logo { .logo {
font-size: 18px; font-size: 18px;
} }
@@ -866,11 +965,11 @@ body.theme-light .setting-item select option {
} }
.video-card h4 { .video-card h4 {
font-size: 16px; font-size: calc(16px * var(--card-font-scale, 1));
} }
.video-card p { .video-card p {
font-size: 13px; font-size: calc(13px * var(--card-font-scale, 1));
} }
} }
@@ -894,6 +993,26 @@ body.theme-light .setting-item select option {
.input-row input { .input-row input {
padding: 10px 14px; padding: 10px 14px;
} }
/* 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. Every text control gets 16px on touch
devices; the padding above keeps them looking the same size. */
.search-container input,
.input-row input,
.setting-item select,
.cmdk-input,
input[type="text"],
input[type="search"],
input[type="number"],
input[type="url"],
input[type="email"],
input[type="password"],
input:not([type]),
textarea,
select {
font-size: 16px;
}
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
@@ -983,7 +1102,7 @@ body.theme-light .setting-item select option {
} }
.video-card h4 { .video-card h4 {
font-size: 15px; font-size: calc(15px * var(--card-font-scale, 1));
font-weight: 500; font-weight: 500;
padding: 12px 12px 10px; padding: 12px 12px 10px;
line-height: 1.3; line-height: 1.3;
@@ -1016,7 +1135,7 @@ body.theme-light .setting-item select option {
} }
.video-card p { .video-card p {
font-size: 12px; font-size: calc(12px * var(--card-font-scale, 1));
color: var(--text-secondary); color: var(--text-secondary);
padding: 0 12px 12px 12px; padding: 0 12px 12px 12px;
margin: 0; margin: 0;
@@ -1034,7 +1153,7 @@ body.theme-light .setting-item select option {
} }
.video-tag { .video-tag {
font-size: 11px; font-size: calc(11px * var(--card-font-scale, 1));
color: var(--text-secondary); color: var(--text-secondary);
background: var(--bg-tertiary); background: var(--bg-tertiary);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -1284,6 +1403,31 @@ body.theme-light .favorite-btn {
gap: 10px; gap: 10px;
} }
/* The panel shows every field the client has, which for a resolved video is the
extractor's whole payload -- dozens of rows. Give the list its own scroll so
the card stays inside the viewport and the close button stays put. */
.info-list {
max-height: min(70vh, 620px);
overflow-y: auto;
padding-right: 4px;
}
.info-section {
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: 0.8px;
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
padding: 6px 0;
}
.info-pending {
font-size: 12px;
color: var(--text-secondary);
padding-top: 4px;
}
.info-row { .info-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -1334,75 +1478,486 @@ body.theme-light .favorite-btn {
padding-top: 6px; padding-top: 6px;
} }
/* Modal */ /* --- Custom player: single fake-fullscreen state everywhere ------------ */
.modal { .custom-player {
display: none; display: none;
position: fixed; position: fixed;
inset: 0; inset: 0;
background: #000; background: #000;
z-index: 2000; z-index: 3000;
touch-action: none;
transition: transform 0.25s ease, opacity 0.25s ease;
} }
body.theme-light .modal { .custom-player.open {
background: #0b0b0b; display: block;
animation: cp-open 0.22s ease;
} }
.modal.open { @keyframes cp-open {
display: flex; from { opacity: 0; }
to { opacity: 1; }
} }
.modal-content { .cp-surface {
position: absolute;
inset: 0;
}
/* Ambient backdrop: a blurred copy of the poster fills any letterbox bars
behind the contained video (set via the --poster custom property). */
.cp-surface::before {
content: '';
position: absolute;
inset: 0;
background-image: var(--poster);
background-size: cover;
background-position: center;
filter: blur(60px) saturate(1.3) brightness(0.5);
transform: scale(1.25);
opacity: 0.55;
z-index: 0;
pointer-events: none;
}
.cp-video {
position: relative;
z-index: 1;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain;
background: #000;
}
.cp-flash {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 8px 18px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 16px;
font-weight: 500;
letter-spacing: 0.02em;
pointer-events: none;
opacity: 0;
z-index: 5;
}
.cp-flash.is-visible {
animation: cp-flash-fade 0.7s ease forwards;
}
@keyframes cp-flash-fade {
0% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(1.08); }
}
.cp-spinner {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
z-index: 4;
}
.cp-spinner.is-visible {
opacity: 1;
}
.cp-spinner-ring {
width: 44px;
height: 44px;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.25);
border-top-color: var(--accent);
animation: cp-spin 0.8s linear infinite;
}
@keyframes cp-spin {
to { transform: rotate(360deg); }
}
.cp-error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 12px;
padding: 20px; padding: 20px 24px;
box-sizing: border-box; background: rgba(0, 0, 0, 0.7);
position: relative; border-radius: var(--radius-lg);
color: #fff;
text-align: center;
max-width: min(360px, 80vw);
z-index: 5;
} }
.close { .cp-error-actions {
position: absolute; display: flex;
top: 20px; gap: 10px;
right: 20px; }
color: #fff;
font-size: 32px; .cp-retry-btn,
.cp-open-btn {
padding: 8px 20px;
border-radius: 999px;
border: 1px solid var(--accent);
background: transparent;
color: var(--accent);
cursor: pointer; cursor: pointer;
background: rgba(0, 0, 0, 0.5); font: inherit;
border: none; text-decoration: none;
line-height: 1.2;
}
.cp-retry-btn:hover,
.cp-open-btn:hover {
background: rgba(201, 165, 103, 0.15);
}
.cp-open-btn[hidden] {
display: none;
}
.cp-replay-btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 64px;
height: 64px;
border-radius: 50%; border-radius: 50%;
width: 44px; border: none;
height: 44px; background: rgba(0, 0, 0, 0.6);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 2001; cursor: pointer;
transition: all 0.2s ease; z-index: 5;
} }
.close:hover { .cp-replay-btn .icon-svg {
background: rgba(255, 255, 255, 0.2); width: 30px;
height: 30px;
filter: invert(1);
} }
video { .cp-hud {
width: 100%; position: absolute;
height: 100%; inset: 0;
max-width: 100%; display: flex;
max-height: 100vh; flex-direction: column;
object-fit: contain; justify-content: space-between;
pointer-events: none;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.55), transparent 18%, transparent 78%, rgba(0, 0, 0, 0.6));
opacity: 1;
transition: opacity 0.3s ease;
z-index: 3;
} }
#mobile-video-host { .custom-player.cp-hud-idle .cp-hud {
position: fixed; opacity: 0;
left: -9999px; }
top: 0;
width: 1px; .cp-hud * {
height: 1px; pointer-events: auto;
}
.cp-top-bar {
display: flex;
align-items: center;
gap: 12px;
padding: max(14px, env(safe-area-inset-top)) 16px 0;
}
.cp-close-btn {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 18px;
cursor: pointer;
}
.cp-title {
flex: 1;
min-width: 0;
margin: 0;
font-family: var(--font-display);
font-size: 16px;
color: #fff;
white-space: nowrap;
overflow: hidden; overflow: hidden;
} }
.cp-title-text {
display: inline-block;
padding-right: 24px;
transform: translateX(0);
}
.cp-title.has-marquee .cp-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
}
.cp-fav-btn {
position: static;
flex-shrink: 0;
}
.cp-bottom-bar {
padding: 0 16px max(14px, env(safe-area-inset-bottom));
}
.custom-player.is-live .cp-timeline,
.custom-player.is-live .cp-skip-back-btn,
.custom-player.is-live .cp-skip-fwd-btn {
display: none;
}
.cp-timeline {
padding: 10px 0;
cursor: pointer;
}
.cp-timeline-track {
position: relative;
height: 4px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.25);
}
.cp-timeline-buffered {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%;
border-radius: 2px;
background: rgba(255, 255, 255, 0.35);
}
.cp-timeline-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 0%;
border-radius: 2px;
background: var(--accent);
}
.cp-timeline-handle {
position: absolute;
top: 50%;
left: 0%;
width: 13px;
height: 13px;
border-radius: 50%;
background: var(--accent);
transform: translate(-50%, -50%) scale(0.7);
transition: transform 0.15s ease;
}
.cp-timeline:hover .cp-timeline-handle,
.cp-timeline.is-scrubbing .cp-timeline-handle {
transform: translate(-50%, -50%) scale(1);
}
.cp-time-row {
display: flex;
align-items: center;
gap: 10px;
}
.cp-time {
color: #fff;
font-size: 12px;
font-variant-numeric: tabular-nums;
flex-shrink: 0;
min-width: 34px;
}
.cp-time-duration {
text-align: right;
}
.cp-transport {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
justify-content: center;
}
.cp-transport button {
position: relative;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cp-play-btn {
width: 48px !important;
height: 48px !important;
background: rgba(255, 255, 255, 0.12) !important;
}
.cp-play-btn .icon-svg {
width: 22px;
height: 22px;
}
.cp-skip-back-btn .icon-svg,
.cp-skip-fwd-btn .icon-svg {
width: 20px;
height: 20px;
}
.cp-skip-amount {
position: absolute;
bottom: 2px;
font-size: 9px;
font-weight: 600;
background: rgba(0, 0, 0, 0.6);
border-radius: 6px;
padding: 0 3px;
pointer-events: none;
}
.cp-secondary-controls {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.cp-mute-btn,
.cp-pip-btn {
width: 34px;
height: 34px;
border-radius: 50%;
border: none;
background: transparent;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cp-mute-btn .icon-svg,
.cp-pip-btn .icon-svg {
width: 18px;
height: 18px;
}
.cp-volume-range {
width: 64px;
accent-color: var(--accent);
}
.cp-format-btn {
height: 26px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.35);
background: rgba(0, 0, 0, 0.4);
color: #fff;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.cp-format-menu {
position: absolute;
right: 16px;
bottom: 64px;
display: flex;
flex-direction: column;
background: rgba(20, 17, 13, 0.92);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
z-index: 6;
max-height: 40vh;
overflow-y: auto;
}
.cp-format-option {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 18px 9px 14px;
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
text-align: left;
cursor: pointer;
white-space: nowrap;
}
/* A fixed-width tick column so every label starts on the same x, with only
the playing format's tick actually inked. */
.cp-format-option::before {
content: '\2713';
width: 1em;
flex: none;
opacity: 0;
font-size: 12px;
}
.cp-format-option:hover {
background: var(--bg-tertiary);
}
.cp-format-option.is-active {
color: var(--accent);
font-weight: 600;
}
.cp-format-option.is-active::before {
opacity: 1;
}
/* `.cp-error`/`.cp-replay-btn`/`.cp-format-menu` above set `display` on the
bare class so JS can toggle visibility via the `hidden` attribute alone;
these higher-specificity overrides keep that attribute effective (an
unconditional `display` on the class would otherwise beat the UA
`[hidden] { display: none }` rule). */
.cp-error[hidden],
.cp-replay-btn[hidden],
.cp-format-menu[hidden],
.cp-pip-btn[hidden],
.favorite-btn[hidden] {
display: none;
}
@media (max-width: 480px) {
.cp-title { font-size: 14px; }
.cp-volume-range { display: none; }
}
.error-toast { .error-toast {
position: fixed; position: fixed;
right: 20px; right: 20px;
@@ -1648,7 +2203,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
transform: translateX(0); transform: translateX(0);
} }
.feed-title.is-marquee .feed-title-text { .feed-title.has-marquee .feed-title-text {
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite; animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
will-change: transform; will-change: transform;
} }
@@ -1776,6 +2331,38 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
background: rgba(255, 59, 48, 0.18); background: rgba(255, 59, 48, 0.18);
} }
/* Reels HUD right rail additions: PiP + quality, stacked above favorite. */
.feed-pip-btn {
position: absolute;
right: 24px;
bottom: 266px;
z-index: 6;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.18);
transition: background 0.2s ease, opacity 0.4s ease;
}
.feed-pip-btn .icon-svg {
filter: invert(100%) saturate(0%);
}
.feed-format-btn {
position: absolute;
right: 24px;
bottom: 322px;
z-index: 6;
transition: opacity 0.4s ease;
}
.feed-format-menu {
right: 76px;
bottom: 322px;
}
.feed-flash {
z-index: 5;
}
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay /* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
interactive (pointer-events untouched) so the buttons keep working while interactive (pointer-events untouched) so the buttons keep working while
invisible; any pointer/scroll activity reveals them again (see App.feed). */ invisible; any pointer/scroll activity reveals them again (see App.feed). */
@@ -1783,6 +2370,8 @@ body.feed-hud-idle .feed-info,
body.feed-hud-idle .feed-timeline, body.feed-hud-idle .feed-timeline,
body.feed-hud-idle .feed-mute-btn, body.feed-hud-idle .feed-mute-btn,
body.feed-hud-idle .feed-fav-btn, body.feed-hud-idle .feed-fav-btn,
body.feed-hud-idle .feed-pip-btn,
body.feed-hud-idle .feed-format-btn,
body.feed-hud-idle .mode-toggle-btn { body.feed-hud-idle .mode-toggle-btn {
opacity: 0; opacity: 0;
} }
@@ -2017,26 +2606,6 @@ body.theme-light .video-card img:not(.is-loaded) {
flex-shrink: 0; flex-shrink: 0;
} }
/* --- Player ambient backdrop: blurred poster behind the video --------- */
.modal-content::before {
content: '';
position: absolute;
inset: 0;
background-image: var(--poster);
background-size: cover;
background-position: center;
filter: blur(60px) saturate(1.3) brightness(0.5);
transform: scale(1.25);
opacity: 0.55;
z-index: 0;
pointer-events: none;
}
.modal-content > * {
position: relative;
z-index: 1;
}
/* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */ /* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */
.feed-title { font-family: var(--font-display); } .feed-title { font-family: var(--font-display); }
@@ -2058,10 +2627,3 @@ body.theme-light .video-card img:not(.is-loaded) {
pointer-events: none; pointer-events: none;
} }
/* --- View Transition timing (player open/close crossfade) -------------- */
@media (prefers-reduced-motion: no-preference) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.32s;
}
}

View File

@@ -34,6 +34,10 @@
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites"> <section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
<div class="favorites-header"> <div class="favorites-header">
<h3>Favorites</h3> <h3>Favorites</h3>
<div class="favorites-actions">
<select id="favorites-sort" class="favorites-sort" aria-label="Sort favorites"></select>
<button id="favorites-browse-btn" class="btn-secondary" type="button" aria-pressed="false">Browse all</button>
</div>
</div> </div>
<div id="favorites-list" class="favorites-list"></div> <div id="favorites-list" class="favorites-list"></div>
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div> <div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
@@ -98,6 +102,14 @@
<option value="compact">Compact</option> <option value="compact">Compact</option>
</select> </select>
</div> </div>
<div class="setting-item">
<label for="card-size-range">Card Size</label>
<input type="range" id="card-size-range" min="0.7" max="1.5" step="0.1" value="1">
</div>
<div class="setting-item">
<label for="text-size-range">Text Size</label>
<input type="range" id="text-size-range" min="0.8" max="1.4" step="0.1" value="1">
</div>
<div class="setting-item"> <div class="setting-item">
<label for="feed-end-select">Reels: On Video End</label> <label for="feed-end-select">Reels: On Video End</label>
<select id="feed-end-select"> <select id="feed-end-select">
@@ -125,6 +137,17 @@
</div> </div>
<div id="sources-list" class="sources-list"></div> <div id="sources-list" class="sources-list"></div>
</div> </div>
<div class="sidebar-section">
<h4 class="sidebar-subtitle">Hot Tub Backup</h4>
<div class="setting-item">
<label for="import-favorites-btn">Import Favorites</label>
<input id="import-favorites-file" type="file" accept=".sqlite3,.sqlite,.db" hidden>
<button id="import-favorites-btn" class="btn-secondary" type="button">Choose backup file…</button>
<p id="import-favorites-status" class="setting-note" role="status" aria-live="polite">
Reads the favorites out of an exported Hot Tub database. The file stays on this device.
</p>
</div>
</div>
</div> </div>
</aside> </aside>
@@ -134,12 +157,7 @@
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/chevron-down.svg" alt="Load More"> <img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/chevron-down.svg" alt="Load More">
</button> </button>
<div id="video-modal" class="modal"> <div id="custom-player" class="custom-player" aria-hidden="true"></div>
<div class="modal-content">
<span class="close" onclick="closePlayer()">&times;</span>
<video id="player" controls autoplay playsinline webkit-playsinline></video>
</div>
</div>
<button id="mode-toggle-btn" class="mode-toggle-btn" type="button" title="Switch to Reels view" aria-pressed="false"> <button id="mode-toggle-btn" class="mode-toggle-btn" type="button" title="Switch to Reels view" aria-pressed="false">
<img class="icon-svg" id="mode-toggle-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg" alt="Switch to Reels view"> <img class="icon-svg" id="mode-toggle-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/device-phone-mobile.svg" alt="Switch to Reels view">
@@ -184,9 +202,14 @@
</div> </div>
<script src="static/js/state.js"></script> <script src="static/js/state.js"></script>
<script src="static/js/marquee.js"></script>
<script src="static/js/storage.js"></script> <script src="static/js/storage.js"></script>
<script src="static/js/customPlayer.js"></script>
<script src="static/js/player.js"></script> <script src="static/js/player.js"></script>
<script src="static/js/favorites.js"></script> <script src="static/js/favorites.js"></script>
<script src="static/js/favoritesView.js"></script>
<script src="static/js/sqlite.js"></script>
<script src="static/js/hottubBackup.js"></script>
<script src="static/js/videos.js"></script> <script src="static/js/videos.js"></script>
<script src="static/js/feed.js"></script> <script src="static/js/feed.js"></script>
<script src="static/js/ui.js"></script> <script src="static/js/ui.js"></script>

338
frontend/js/customPlayer.js Normal file
View File

@@ -0,0 +1,338 @@
window.App = window.App || {};
App.customPlayer = App.customPlayer || {};
// Shared building blocks for the custom video player HUD, reused by the
// standalone fullscreen player (player.js) and the reels feed (feed.js) so
// both present identical skip/format/gesture/PiP behavior.
(function() {
// -----------------------------------------------------------------
// Skip escalation: tapping skip-forward/back repeatedly ramps the skip
// duration up (5 -> 10 -> 20 -> 40 -> 60s), independently per direction,
// so mashing forward doesn't also ramp up backward. A tap lands as
// "rapid" (and escalates further) only if it arrives within
// RAPID_WINDOW_MS of the previous same-direction tap. After GRACE_MS of
// silence the level steps back down by one every DECAY_STEP_MS.
// -----------------------------------------------------------------
const LEVELS = [5, 10, 20, 40, 60];
const RAPID_WINDOW_MS = 1500;
const GRACE_MS = 2000;
const DECAY_STEP_MS = 1000;
App.customPlayer.createSkipEscalator = function() {
const dirs = {
back: { levelIndex: 0, lastTapAt: 0, decayTimer: null },
forward: { levelIndex: 0, lastTapAt: 0, decayTimer: null }
};
const clearDecay = (d) => {
if (d.decayTimer) {
clearTimeout(d.decayTimer);
d.decayTimer = null;
}
};
const scheduleDecay = (d) => {
clearDecay(d);
d.decayTimer = setTimeout(function tick() {
d.decayTimer = null;
if (d.levelIndex > 0) {
d.levelIndex -= 1;
d.decayTimer = setTimeout(tick, DECAY_STEP_MS);
}
}, GRACE_MS);
};
// Advances the state for a tap in `direction` and returns the number
// of seconds that tap should skip.
const trigger = function(direction) {
const d = dirs[direction];
if (!d) return LEVELS[0];
const now = Date.now();
if (now - d.lastTapAt <= RAPID_WINDOW_MS && d.levelIndex < LEVELS.length - 1) {
d.levelIndex += 1;
}
d.lastTapAt = now;
scheduleDecay(d);
return LEVELS[d.levelIndex];
};
const destroy = function() {
clearDecay(dirs.back);
clearDecay(dirs.forward);
};
return { trigger, destroy };
};
// Applies one skip tap to `video` using `escalator`, clamped to the
// media's bounds. Returns the number of seconds skipped (for HUD flash
// feedback), or 0 if the video has no usable duration yet.
App.customPlayer.skip = function(video, direction, escalator) {
if (!video) return 0;
const amount = escalator.trigger(direction);
const delta = direction === 'forward' ? amount : -amount;
let target = video.currentTime + delta;
if (isFinite(video.duration) && video.duration > 0) {
target = Math.min(target, Math.max(0, video.duration - 0.1));
}
video.currentTime = Math.max(0, target);
return amount;
};
// -----------------------------------------------------------------
// Format switching: builds a labeled, ranked list of a video's playable
// formats (quality/codec/container variants) for a picker menu. Reuses
// App.videos.rankFormats (same ranking as automatic selection) with no
// preferred-height ceiling, since a manual pick overrides that entirely.
// -----------------------------------------------------------------
App.customPlayer.formatLabel = function(fmt) {
if (!fmt) return 'Auto';
const parts = [];
const height = App.videos.coerceNumber(fmt.height);
const fps = App.videos.coerceNumber(fmt.fps);
if (height) parts.push(`${height}p${fps > 30 ? Math.round(fps) : ''}`);
// The container (mp4/webm) tells the viewer nothing useful about a
// quality choice. The extractor's own note does -- but only when it
// says something the quality doesn't already ("HDR", "source", a
// codec), so drop one that merely restates it ("1080p", "1080p60").
const note = (fmt.format_note || '').toString().trim();
if (note && note.toLowerCase() !== (parts[0] || '').toLowerCase()) parts.push(note);
if (!parts.length) {
const vcodec = (fmt.vcodec || '').toString();
parts.push(vcodec && vcodec !== 'none' ? vcodec : 'Auto');
}
return parts.join(' ');
};
// Returns [] when there's nothing to pick from (no formats, or only one
// usable variant) so callers know to hide the format-switch button.
App.customPlayer.buildFormatOptions = function(video) {
const meta = video && (video.meta || video);
if (!meta || !Array.isArray(meta.formats) || meta.formats.length < 2) return [];
const ranked = App.videos.rankFormats(meta.formats, null);
if (ranked.length < 2) return [];
return ranked.map((fmt) => ({ fmt, label: App.customPlayer.formatLabel(fmt) }));
};
// Wires a format-switch button + its dropdown menu against `videoData`,
// calling onSelect(fmt) when the user picks one. Hides the button when
// there's nothing to pick from. Shared by the standalone player and the
// reels feed so both present an identical menu. Returns a destroy() fn.
// `options.getCurrentUrl` (optional) returns the URL the player is actually
// feeding to the media element right now; the matching entry is marked
// active every time the menu opens. Reading it live rather than at bind
// time keeps the mark honest when playback moved on by itself -- an
// automatic pick, a fallback to the next candidate after a failure, or a
// re-resolve -- not just when the viewer chose from this menu.
App.customPlayer.bindFormatMenu = function(btn, menu, videoData, onSelect, opts) {
if (!btn || !menu) return function destroy() {};
const getCurrentUrl = (opts && opts.getCurrentUrl) || null;
const options = App.customPlayer.buildFormatOptions(videoData);
if (!options.length) {
btn.hidden = true;
menu.hidden = true;
menu.innerHTML = '';
return function destroy() {};
}
btn.hidden = false;
menu.hidden = true;
menu.innerHTML = options.map((opt, i) =>
`<button class="cp-format-option" type="button" role="menuitemradio" aria-checked="false" data-index="${i}">${opt.label}</button>`
).join('');
const markActive = (activeBtn) => {
menu.querySelectorAll('.cp-format-option').forEach((b) => {
const isActive = b === activeBtn;
b.classList.toggle('is-active', isActive);
b.setAttribute('aria-checked', isActive ? 'true' : 'false');
});
};
const syncActive = () => {
const current = getCurrentUrl ? (getCurrentUrl() || '') : '';
let match = null;
if (current) {
options.forEach((opt, i) => {
if (!match && opt.fmt && opt.fmt.url === current) {
match = menu.querySelector(`.cp-format-option[data-index="${i}"]`);
}
});
}
markActive(match);
};
const cleanups = [];
menu.querySelectorAll('.cp-format-option').forEach((optBtn) => {
const onClick = (event) => {
event.stopPropagation();
const idx = parseInt(optBtn.dataset.index, 10);
const opt = options[idx];
menu.hidden = true;
markActive(optBtn);
if (opt) onSelect(opt.fmt);
};
optBtn.addEventListener('click', onClick);
cleanups.push(() => optBtn.removeEventListener('click', onClick));
});
const onBtnClick = (event) => {
event.stopPropagation();
if (menu.hidden) {
syncActive();
// Opening the menu restarts the HUD's idle countdown: the menu
// hides with the HUD, and the viewer needs the full window to
// read the list, not whatever was left of the previous one.
if (opts && opts.onOpen) opts.onOpen();
}
menu.hidden = !menu.hidden;
};
btn.addEventListener('click', onBtnClick);
cleanups.push(() => btn.removeEventListener('click', onBtnClick));
return function destroy() {
cleanups.forEach((fn) => fn());
};
};
// -----------------------------------------------------------------
// Picture-in-Picture: a manual toggle plus best-effort auto-PiP when the
// tab/app is backgrounded while a video is playing (Safari does this
// natively for inline video; Chrome/Android need an explicit call).
// -----------------------------------------------------------------
App.customPlayer.supportsPiP = function() {
return !!(document.pictureInPictureEnabled);
};
App.customPlayer.togglePiP = async function(video) {
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
try {
if (document.pictureInPictureElement === video) {
await document.exitPictureInPicture();
} else {
await video.requestPictureInPicture();
}
return true;
} catch (err) {
return false;
}
};
App.customPlayer.bindAutoPiP = function(video) {
if (!video) return function destroy() {};
const trigger = () => {
if (document.visibilityState !== 'hidden') return;
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
if (document.pictureInPictureElement) return;
if (video.paused || video.ended) return;
video.requestPictureInPicture().catch(() => {});
};
document.addEventListener('visibilitychange', trigger);
window.addEventListener('pagehide', trigger);
return function destroy() {
document.removeEventListener('visibilitychange', trigger);
window.removeEventListener('pagehide', trigger);
};
};
// -----------------------------------------------------------------
// Unified pointer-gesture recognizer for the video surface: a single
// pointer stream is classified into exactly one of tap / double-tap /
// volume-drag (right column) / dismiss-drag (top strip), so the gestures
// never fight each other over the same touch.
// -----------------------------------------------------------------
App.customPlayer.attachGestures = function(surfaceEl, handlers) {
handlers = handlers || {};
const TAP_MAX_MOVE = 10;
const DOUBLE_TAP_MS = 300;
const DISMISS_ZONE_FRACTION = 0.2; // top strip that owns swipe-to-dismiss
const VOLUME_ZONE_START = 0.66; // right column that owns volume swipe
const SKIP_ZONE_LEFT_END = 0.34;
const SKIP_ZONE_RIGHT_START = 0.66;
let pointerId = null;
let startX = 0, startY = 0, lastY = 0;
let moved = false;
let mode = null; // 'dismiss-candidate' | 'dismiss' | 'volume-candidate' | 'volume' | 'ignore'
let volumeStartValue = 0;
let lastTapTime = 0;
let lastTapSide = null;
const rectOf = () => surfaceEl.getBoundingClientRect();
const ignoreSelector = handlers.ignoreSelector || 'button, input, a, .cp-format-menu';
const onPointerDown = (e) => {
if (pointerId != null || e.button != null && e.button !== 0) return;
if (e.target && e.target.closest && e.target.closest(ignoreSelector)) return;
pointerId = e.pointerId;
startX = e.clientX;
startY = lastY = e.clientY;
moved = false;
mode = null;
const rect = rectOf();
const relX = rect.width ? (e.clientX - rect.left) / rect.width : 0;
const relY = rect.height ? (e.clientY - rect.top) / rect.height : 0;
if (relY <= DISMISS_ZONE_FRACTION && handlers.onDismissDrag) {
mode = 'dismiss-candidate';
} else if (relX >= VOLUME_ZONE_START && handlers.onVolumeDrag) {
mode = 'volume-candidate';
volumeStartValue = handlers.onVolumeStart ? handlers.onVolumeStart() : 0;
}
try { surfaceEl.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
};
const onPointerMove = (e) => {
if (e.pointerId !== pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && Math.hypot(dx, dy) > TAP_MAX_MOVE) moved = true;
if (moved) {
if (mode === 'dismiss-candidate') mode = 'dismiss';
else if (mode === 'volume-candidate') mode = 'volume';
else if (mode === null) mode = 'ignore';
if (mode === 'dismiss') {
handlers.onDismissDrag(dy, rectOf());
} else if (mode === 'volume') {
const rect = rectOf();
const deltaRatio = rect.height ? (startY - e.clientY) / rect.height : 0;
handlers.onVolumeDrag(Math.min(1, Math.max(0, volumeStartValue + deltaRatio)));
}
}
lastY = e.clientY;
};
const endGesture = (e) => {
if (e.pointerId !== pointerId) return;
try { surfaceEl.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
if (moved) {
if (mode === 'dismiss' && handlers.onDismissEnd) handlers.onDismissEnd(lastY - startY);
else if (mode === 'volume' && handlers.onVolumeEnd) handlers.onVolumeEnd();
} else {
if (handlers.onSingleTap) handlers.onSingleTap();
const rect = rectOf();
const relX = rect.width ? (startX - rect.left) / rect.width : 0.5;
const side = relX <= SKIP_ZONE_LEFT_END ? 'left' : (relX >= SKIP_ZONE_RIGHT_START ? 'right' : 'center');
const now = Date.now();
if (side !== 'center' && lastTapSide === side && (now - lastTapTime) <= DOUBLE_TAP_MS) {
lastTapTime = 0;
lastTapSide = null;
if (side === 'left' && handlers.onDoubleTapLeft) handlers.onDoubleTapLeft();
if (side === 'right' && handlers.onDoubleTapRight) handlers.onDoubleTapRight();
} else {
lastTapTime = now;
lastTapSide = side;
}
}
pointerId = null;
mode = null;
};
surfaceEl.addEventListener('pointerdown', onPointerDown);
surfaceEl.addEventListener('pointermove', onPointerMove);
surfaceEl.addEventListener('pointerup', endGesture);
surfaceEl.addEventListener('pointercancel', endGesture);
return function destroy() {
surfaceEl.removeEventListener('pointerdown', onPointerDown);
surfaceEl.removeEventListener('pointermove', onPointerMove);
surfaceEl.removeEventListener('pointerup', endGesture);
surfaceEl.removeEventListener('pointercancel', endGesture);
};
};
})();

View File

@@ -79,7 +79,7 @@ App.enhance = App.enhance || {};
const ready = meta && Array.isArray(meta.formats) && meta.formats.length; const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
if (!ready) { if (!ready) {
// Not resolved yet: kick it off so the *next* hover can preview. // Not resolved yet: kick it off so the *next* hover can preview.
if (typeof App.videos.resolveAndProbe === 'function') App.videos.resolveAndProbe(v); if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v);
return; return;
} }
let url = ''; let url = '';
@@ -151,6 +151,26 @@ App.enhance = App.enhance || {};
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout(); if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
}}); }});
out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } }); out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } });
// The favorites bar carries these controls, but it can be switched
// off in settings -- in which case the palette is the way in.
if (App.favoritesView && App.favorites) {
const browsing = App.favoritesView.isActive();
out.push({
label: browsing ? 'Back to videos' : 'Browse favorites',
hint: browsing ? 'Leave the favorites grid' : 'All favorites as a grid',
run: () => App.favoritesView.toggle()
});
const currentSort = App.favorites.getSort();
App.favorites.SORTS.forEach((sort) => {
if (sort.id === currentSort) return;
out.push({
label: `Sort favorites: ${sort.label}`,
hint: 'Favorites',
run: () => App.favoritesView.applySort(sort.id)
});
});
}
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } }); out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } }); out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } }); out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });

View File

@@ -9,12 +9,83 @@ App.favorites = App.favorites || {};
try { try {
const raw = localStorage.getItem(FAVORITES_KEY); const raw = localStorage.getItem(FAVORITES_KEY);
const parsed = raw ? JSON.parse(raw) : []; const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : []; if (!Array.isArray(parsed)) return [];
// Two things are repaired on the way in, and written back once if
// anything changed, so the fix happens exactly one time:
//
// `meta`, from older versions, is a blob of resolved formats whose
// URLs are signed and long expired -- dropped so no code path can
// reach for one; everything re-resolves from `url` at play time.
//
// `favoriteDate` didn't exist before sorting needed it. There's no
// way to recover when an old favorite was actually saved, so it
// gets now: they sort together, as one batch, at the point the
// client learned to keep dates.
let repaired = false;
const now = new Date().toISOString();
const items = parsed.map((item) => {
if (!item || typeof item !== 'object') return item;
if (!item.meta && item.favoriteDate) return item;
const clean = Object.assign({}, item);
delete clean.meta;
if (!clean.favoriteDate) clean.favoriteDate = now;
repaired = true;
return clean;
});
if (repaired) App.favorites.setAll(items);
return items;
} catch (err) { } catch (err) {
return []; return [];
} }
}; };
// Sort orders offered for the favorites bar and the favorites grid.
// `random` reshuffles on every read by design -- it's for rediscovering a
// long list, so landing somewhere different each time is the point.
App.favorites.SORTS = [
{ id: 'recent', label: 'Recently added' },
{ id: 'oldest', label: 'Oldest first' },
{ id: 'title', label: 'Title A-Z' },
{ id: 'longest', label: 'Longest' },
{ id: 'shortest', label: 'Shortest' },
{ id: 'random', label: 'Shuffle' }
];
App.favorites.DEFAULT_SORT = 'recent';
App.favorites.getSort = function() {
const stored = localStorage.getItem(App.constants.FAVORITES_SORT_KEY);
return App.favorites.SORTS.some((sort) => sort.id === stored) ? stored : App.favorites.DEFAULT_SORT;
};
App.favorites.setSort = function(sort) {
localStorage.setItem(App.constants.FAVORITES_SORT_KEY, sort);
};
const dateValue = function(item) {
const parsed = Date.parse((item && item.favoriteDate) || '');
return isNaN(parsed) ? 0 : parsed;
};
App.favorites.sorted = function(sort) {
const items = App.favorites.getAll();
const mode = sort || App.favorites.getSort();
if (mode === 'random') {
for (let i = items.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[items[i], items[j]] = [items[j], items[i]];
}
return items;
}
const comparators = {
recent: (a, b) => dateValue(b) - dateValue(a),
oldest: (a, b) => dateValue(a) - dateValue(b),
title: (a, b) => String(a.title || '').localeCompare(String(b.title || ''), undefined, { sensitivity: 'base' }),
longest: (a, b) => (Number(b.duration) || 0) - (Number(a.duration) || 0),
shortest: (a, b) => (Number(a.duration) || 0) - (Number(b.duration) || 0)
};
return items.sort(comparators[mode] || comparators.recent);
};
App.favorites.setAll = function(items) { App.favorites.setAll = function(items) {
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items)); localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
}; };
@@ -32,21 +103,112 @@ App.favorites = App.favorites || {};
return { return {
key, key,
id: video.id || null, id: video.id || null,
url: video.url || '', // The page/source URL (e.g. the YouTube watch URL), not a resolved
// CDN media URL -- those expire, so favorites must always re-resolve
// via the server at play time instead of caching a stream link.
url: video.url || (meta && meta.url) || '',
title: video.title || '', title: video.title || '',
thumb: video.thumb || '', thumb: video.thumb || '',
channel: video.channel || (meta && meta.channel) || '', channel: video.channel || (meta && meta.channel) || '',
uploader: video.uploader || (meta && meta.uploader) || '', uploader: video.uploader || (meta && meta.uploader) || '',
duration: video.duration || (meta && meta.duration) || 0, duration: video.duration || (meta && meta.duration) || 0,
isLive: !!(video.isLive || (meta && meta.isLive)), isLive: !!(video.isLive || (meta && meta.isLive)),
meta: meta // When it was saved. An import carries the date the other client
// recorded; anything saved here is saved now.
favoriteDate: video.favoriteDate || new Date().toISOString()
// No `meta` field: persisting resolved formats would freeze their
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
// favorite look like a fresh, unresolved listing item again, so
// playback/download/info all re-resolve through the backend from
// `url` -- see resolveStreamSources' no-formats fallback, which the
// backend resolves live via yt-dlp (main.py stream_video).
}; };
}; };
// Identity across sources. Favorites added here are keyed by the server's
// id; ones imported from a Hot Tub backup can only be keyed by URL (the app
// keys videos by a hash of its own). Comparing normalized URLs is what
// stops the same video being listed twice under two different keys.
App.favorites.urlKey = function(url) {
const raw = String(url || '').trim();
if (!raw) return '';
try {
const parsed = new URL(raw, window.location.href);
const host = parsed.host.replace(/^www\./i, '').toLowerCase();
const path = parsed.pathname.replace(/\/+$/, '');
return `${host}${path}${parsed.search}`;
} catch (err) {
return raw.toLowerCase();
}
};
// Adds favorites from an import, skipping any this client already has.
// Existing entries are left exactly as they are -- they carry the server id
// that makes a listing card's heart light up, which an imported entry has
// no way to know -- and new ones are appended after them.
App.favorites.mergeImported = function(entries) {
const incoming = Array.isArray(entries) ? entries : [];
const favorites = App.favorites.getAll();
const keys = new Set();
const urls = new Set();
favorites.forEach((item) => {
if (!item) return;
if (item.key) keys.add(item.key);
const urlKey = App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
let added = 0;
let skipped = 0;
incoming.forEach((entry) => {
if (!entry || !entry.key) return;
const urlKey = App.favorites.urlKey(entry.url);
if (keys.has(entry.key) || (urlKey && urls.has(urlKey))) {
skipped++;
return;
}
keys.add(entry.key);
if (urlKey) urls.add(urlKey);
favorites.push(entry);
added++;
});
if (added) {
App.favorites.setAll(favorites);
App.favorites.renderBar();
App.favorites.syncButtons();
}
return { added, skipped, total: favorites.length };
};
App.favorites.getSet = function() { App.favorites.getSet = function() {
return new Set(App.favorites.getAll().map((item) => item.key)); return new Set(App.favorites.getAll().map((item) => item.key));
}; };
// Same set, addressed by URL. Imported favorites are keyed by URL rather
// than by a server id, so a listing card can only recognise one this way.
App.favorites.getUrlSet = function() {
const urls = new Set();
App.favorites.getAll().forEach((item) => {
const urlKey = item && App.favorites.urlKey(item.url);
if (urlKey) urls.add(urlKey);
});
return urls;
};
// Is this video already a favorite, whichever way it got saved? Checked by
// key first, then by URL, so a card and an imported entry for the same
// video are recognised as one thing.
App.favorites.indexOfEntry = function(favorites, video) {
const key = App.favorites.getKey(video);
const byKey = key ? favorites.findIndex((item) => item && item.key === key) : -1;
if (byKey >= 0) return byKey;
const meta = (video && video.meta) || video || {};
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
if (!urlKey) return -1;
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
};
App.favorites.isVisible = function() { App.favorites.isVisible = function() {
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false'; return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
}; };
@@ -65,10 +227,12 @@ App.favorites = App.favorites || {};
App.favorites.syncButtons = function() { App.favorites.syncButtons = function() {
const favoritesSet = App.favorites.getSet(); const favoritesSet = App.favorites.getSet();
const favoriteUrls = App.favorites.getUrlSet();
document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => { document.querySelectorAll('.favorite-btn[data-fav-key]').forEach((button) => {
const key = button.dataset.favKey; const key = button.dataset.favKey;
if (!key) return; const urlKey = App.favorites.urlKey(button.dataset.favUrl);
App.favorites.setButtonState(button, favoritesSet.has(key)); if (!key && !urlKey) return;
App.favorites.setButtonState(button, (key && favoritesSet.has(key)) || (urlKey && favoriteUrls.has(urlKey)));
}); });
}; };
@@ -76,7 +240,9 @@ App.favorites = App.favorites || {};
const key = App.favorites.getKey(video); const key = App.favorites.getKey(video);
if (!key) return; if (!key) return;
const favorites = App.favorites.getAll(); const favorites = App.favorites.getAll();
const existingIndex = favorites.findIndex((item) => item.key === key); // By key or by URL: unfavoriting a card whose video came in from a
// backup must remove that entry, not add a second one beside it.
const existingIndex = App.favorites.indexOfEntry(favorites, video);
const becameFavorite = existingIndex < 0; const becameFavorite = existingIndex < 0;
if (existingIndex >= 0) { if (existingIndex >= 0) {
favorites.splice(existingIndex, 1); favorites.splice(existingIndex, 1);
@@ -98,18 +264,117 @@ App.favorites = App.favorites || {};
} }
}; };
// The bar is a horizontal strip, and a long favorites list is hundreds of
// cards. Only a screenful or so is built up front; the rest arrives as the
// strip is scrolled, which keeps opening the app cheap no matter how many
// favorites are saved (an import can add hundreds at once).
const BAR_PAGE_SIZE = 24;
// How close to the right end the strip has to get before the next page is
// appended -- roughly a screen's worth of cards ahead of the reader.
const BAR_PAGE_AHEAD_PX = 800;
const barPage = { items: [], rendered: 0 };
// Bar titles are one line that scrolls when it doesn't fit, the same as the
// grid card's. Which one scrolls follows the grid too: with a real pointer
// it's the card under it, on touch the card nearest the middle of the strip.
// Animating every overflowing title at once turns the bar into a wall of
// moving text.
const barTitleEnv = {
useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches
};
const setBarTitleActive = function(card, active) {
const title = card && card.querySelector('.favorite-title');
if (!title) return;
card.classList.toggle('is-title-active', !!active && title.classList.contains('has-marquee'));
};
// Touch: the card nearest the centre of the visible strip is the one being
// read, so it is the one whose title scrolls.
const syncBarTitleActive = function(list) {
if (!list) return;
const listRect = list.getBoundingClientRect();
const centre = listRect.left + listRect.width / 2;
let best = null;
let bestDistance = Infinity;
const cards = list.querySelectorAll('.favorite-card');
cards.forEach((card) => {
const rect = card.getBoundingClientRect();
if (rect.right <= listRect.left || rect.left >= listRect.right) return;
const distance = Math.abs((rect.left + rect.width / 2) - centre);
if (distance < bestDistance) {
bestDistance = distance;
best = card;
}
});
cards.forEach((card) => setBarTitleActive(card, card === best));
};
// Measures every card in the strip. Cheap enough to redo wholesale: a card's
// width is fixed, so this only really runs when cards are added.
const measureBarTitles = function(list) {
const target = list || document.getElementById('favorites-list');
if (!target) return;
target.querySelectorAll('.favorite-card').forEach((card) => {
App.marquee.measure(card.querySelector('.favorite-title'),
card.querySelector('.favorite-title-text'));
});
if (!barTitleEnv.useHoverFocus) syncBarTitleActive(target);
};
// The display font arrives after the first render, and it changes how wide
// every title is -- so whatever was measured against the fallback font has
// to be measured again once the real one is in.
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(() => measureBarTitles()).catch(() => {});
}
App.favorites.renderBar = function() { App.favorites.renderBar = function() {
const bar = document.getElementById('favorites-bar'); const bar = document.getElementById('favorites-bar');
const list = document.getElementById('favorites-list'); const list = document.getElementById('favorites-list');
const empty = document.getElementById('favorites-empty'); const empty = document.getElementById('favorites-empty');
if (!bar || !list) return; if (!bar || !list) return;
const favorites = App.favorites.getAll(); const favorites = App.favorites.sorted();
const visible = App.favorites.isVisible(); // While the favorites grid is open the bar is kept mounted even if it's
bar.style.display = visible ? 'block' : 'none'; // switched off in settings: its header is what leads back out.
const browsing = !!(App.favoritesView && App.favoritesView.isActive());
bar.style.display = (App.favorites.isVisible() || browsing) ? 'block' : 'none';
list.innerHTML = ""; list.innerHTML = "";
favorites.forEach((item) => { barPage.items = favorites;
barPage.rendered = 0;
// While the favorites grid is open the strip is hidden -- the grid is
// the same list, larger -- so don't build cards nobody can see. The
// header stays, because it carries the way back out.
if (!browsing) appendBarPage(list);
// Assignment rather than addEventListener: renderBar runs on every
// favorite change, and this must not stack up handlers.
let scrollRaf = null;
list.onscroll = () => {
// Which card is centred changes as the strip moves, but only once
// per frame is worth measuring.
if (!barTitleEnv.useHoverFocus && !scrollRaf) {
scrollRaf = requestAnimationFrame(() => {
scrollRaf = null;
syncBarTitleActive(list);
});
}
if (barPage.rendered >= barPage.items.length) return;
const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth);
if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list);
};
if (empty) {
empty.style.display = favorites.length > 0 ? 'none' : 'block';
}
};
function appendBarPage(list) {
const slice = barPage.items.slice(barPage.rendered, barPage.rendered + BAR_PAGE_SIZE);
barPage.rendered += slice.length;
slice.forEach((item) => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'favorite-card'; card.className = 'favorite-card';
card.dataset.favKey = item.key; card.dataset.favKey = item.key;
@@ -120,14 +385,14 @@ App.favorites = App.favorites || {};
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : ''; const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
card.innerHTML = ` card.innerHTML = `
${liveBadge} ${liveBadge}
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button> <button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}" data-fav-url="${item.url || ''}">♥</button>
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button> <button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
<div class="video-menu" role="menu"> <div class="video-menu" role="menu">
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button> <button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button> <button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
</div> </div>
<div class="video-thumb"> <div class="video-thumb">
<img src="${item.thumb}" alt="${item.title}" loading="lazy" decoding="async"> <img alt="${item.title}" loading="lazy" decoding="async">
<div class="video-loading" aria-hidden="true"> <div class="video-loading" aria-hidden="true">
<div class="video-loading-spinner"></div> <div class="video-loading-spinner"></div>
</div> </div>
@@ -135,17 +400,20 @@ App.favorites = App.favorites || {};
${durationText ? `<span class="video-duration">${durationText}</span>` : ''} ${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
</div> </div>
<div class="favorite-info"> <div class="favorite-info">
<h4>${item.title}</h4> <h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
</div> </div>
`; `;
const thumb = card.querySelector('img'); const thumb = card.querySelector('img');
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') { if (App.videos && typeof App.videos.attachThumbnail === 'function') {
App.videos.attachNoReferrerRetry(thumb); App.videos.attachThumbnail(thumb, item.thumb);
} }
card.onclick = () => { card.onclick = () => {
if (card.classList.contains('is-loading')) return; if (card.classList.contains('is-loading')) return;
card.classList.add('is-loading'); card.classList.add('is-loading');
App.player.open(item.meta || item, { originEl: card }); // Ignore any stale `meta` a favorite saved before this fix may
// still carry in localStorage -- always re-resolve from `item`
// (id/url) so playback never reuses an expired stream URL.
App.player.open(item, { originEl: card });
}; };
const favoriteBtn = card.querySelector('.favorite-btn'); const favoriteBtn = card.querySelector('.favorite-btn');
if (favoriteBtn) { if (favoriteBtn) {
@@ -167,15 +435,20 @@ App.favorites = App.favorites || {};
if (showInfoBtn) { if (showInfoBtn) {
showInfoBtn.onclick = (event) => { showInfoBtn.onclick = (event) => {
event.stopPropagation(); event.stopPropagation();
App.ui.showInfo(item.meta || item);
App.videos.closeAllMenus(); App.videos.closeAllMenus();
// Favorites deliberately store no resolved metadata; the
// panel opens on what the entry holds and resolves the rest
// itself.
App.ui.openInfo(item);
}; };
} }
if (downloadBtn) { if (downloadBtn) {
downloadBtn.onclick = (event) => { downloadBtn.onclick = (event) => {
event.stopPropagation(); event.stopPropagation();
App.videos.downloadVideo(item.meta || item);
App.videos.closeAllMenus(); App.videos.closeAllMenus();
// Same as playback: resolve a real media URL first rather
// than pointing the download at the page URL.
App.videos.ensureFormats(item).then(() => App.videos.downloadVideo(item));
}; };
} }
const uploaderBtn = card.querySelector('.uploader-link'); const uploaderBtn = card.querySelector('.uploader-link');
@@ -186,11 +459,17 @@ App.favorites = App.favorites || {};
App.videos.handleSearch(uploader); App.videos.handleSearch(uploader);
}; };
} }
if (barTitleEnv.useHoverFocus) {
card.addEventListener('pointerenter', () => setBarTitleActive(card, true));
card.addEventListener('pointerleave', () => setBarTitleActive(card, false));
}
// Keyboard: tabbing to a card's heart should reveal its whole title
// too, on touch devices as much as on desktop.
card.addEventListener('focusin', () => setBarTitleActive(card, true));
card.addEventListener('focusout', () => setBarTitleActive(card, false));
list.appendChild(card); list.appendChild(card);
}); });
// Widths only exist once the cards are laid out.
if (empty) { requestAnimationFrame(() => measureBarTitles(list));
empty.style.display = favorites.length > 0 ? 'none' : 'block';
} }
};
})(); })();

View File

@@ -0,0 +1,110 @@
window.App = window.App || {};
App.favoritesView = App.favoritesView || {};
// Browsing favorites as a full grid, the same way the channel listing is
// browsed: same cards, same virtualized masonry, same infinite scroll, same
// reels mode. The only difference is where the pages come from -- localStorage
// instead of the server -- so App.videos.loadVideos routes here while this view
// is active and every page hands its slice to App.videos.renderVideos.
(function() {
const state = App.state;
// A page of a local list can be bigger than a page from the server: there's
// no request behind it, only the cost of building cards, which the
// virtualizer already keeps to what's on screen.
const PAGE_SIZE = 24;
const view = {
active: false,
queue: [], // the sorted favorites still to be handed to the grid
offset: 0
};
// A favorite as the grid expects a video: `id` has to be unique per card
// (the virtualizer and renderedVideoIds key on it), and an imported
// favorite has no server id -- its key, which is the URL, stands in.
const toVideo = function(entry) {
return Object.assign({}, entry, { id: entry.key, tags: [] });
};
App.favoritesView.isActive = function() {
return view.active;
};
App.favoritesView.loadNext = function() {
if (!view.active) return false;
const slice = view.queue.slice(view.offset, view.offset + PAGE_SIZE);
view.offset += slice.length;
state.hasNextPage = view.offset < view.queue.length;
if (!slice.length) {
App.videos.updateLoadMoreState();
return false;
}
App.videos.renderVideos({ items: slice.map(toVideo) });
App.videos.updateLoadMoreState();
return true;
};
// Starts (or restarts, after a sort change) the favorites grid.
App.favoritesView.open = function(options) {
const sort = (options && options.sort) || App.favorites.getSort();
const favorites = App.favorites.sorted(sort);
if (!favorites.length) {
App.ui.showError('No favorites yet. Tap the heart on a video to save one.');
return false;
}
App.videos.resetGrid();
view.active = true;
view.queue = favorites;
view.offset = 0;
state.hasNextPage = true;
document.body.classList.add('favorites-view-open');
// Re-render the bar so it re-decides whether to be mounted: hidden in
// settings or not, its header has to be on screen now, since that's
// where the way back out lives (the palette can open this view too).
App.favorites.renderBar();
App.favoritesView.syncControls();
App.favoritesView.loadNext();
window.scrollTo({ top: 0, behavior: 'auto' });
return true;
};
App.favoritesView.close = function(options) {
if (!view.active) return;
view.active = false;
view.queue = [];
view.offset = 0;
document.body.classList.remove('favorites-view-open');
// Back to whatever the settings say, and with its cards built again.
App.favorites.renderBar();
App.favoritesView.syncControls();
// Back to the channel listing, unless the caller is about to load
// something itself (a search, a channel switch).
if (!(options && options.silent)) App.videos.resetAndReload();
};
App.favoritesView.toggle = function() {
if (view.active) App.favoritesView.close();
else App.favoritesView.open();
};
// Re-pages the grid under a new order, and re-renders the bar so both show
// favorites the same way round.
App.favoritesView.applySort = function(sort) {
App.favorites.setSort(sort);
App.favorites.renderBar();
if (view.active) App.favoritesView.open({ sort });
};
App.favoritesView.syncControls = function() {
const button = document.getElementById('favorites-browse-btn');
if (button) {
button.textContent = view.active ? 'Back to videos' : 'Browse all';
button.setAttribute('aria-pressed', view.active ? 'true' : 'false');
}
const select = document.getElementById('favorites-sort');
if (select && select.value !== App.favorites.getSort()) {
select.value = App.favorites.getSort();
}
};
})();

View File

@@ -52,7 +52,11 @@ App.feed = App.feed || {};
if (hudIdleTimer) clearTimeout(hudIdleTimer); if (hudIdleTimer) clearTimeout(hudIdleTimer);
hudIdleTimer = setTimeout(() => { hudIdleTimer = setTimeout(() => {
hudIdleTimer = null; hudIdleTimer = null;
if (state.feedOpen) document.body.classList.add('feed-hud-idle'); if (!state.feedOpen) return;
document.body.classList.add('feed-hud-idle');
// The quality menu only fades with the rest of the HUD if we close
// it: it's an opened popover, not a permanently mounted control.
document.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
}, HUD_IDLE_MS); }, HUD_IDLE_MS);
}; };
@@ -155,26 +159,11 @@ App.feed = App.feed || {};
slide.classList.remove('is-loaded'); slide.classList.remove('is-loaded');
}; };
// Single-line feed title that scrolls horizontally when it overflows. // Single-line slide title that scrolls when it overflows. Only the active
// Driven off the overflow distance so every title scrolls at the same // slide's title is measured, so like the player it always scrolls.
// gentle speed, matching the grid-card marquee (see App.videos.measureTitle).
const measureFeedTitle = function(slide) { const measureFeedTitle = function(slide) {
if (!slide) return; if (!slide) return;
const wrap = slide.querySelector('.feed-title'); App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
const text = slide.querySelector('.feed-title-text');
if (!wrap || !text) return;
const overflow = text.scrollWidth - wrap.clientWidth;
if (overflow > 4) {
const distance = overflow + 16;
const MARQUEE_SPEED = 28; // px per second
const duration = Math.max(6, distance / MARQUEE_SPEED);
text.style.setProperty('--marquee-distance', `${distance}px`);
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
wrap.classList.add('is-marquee');
} else {
wrap.classList.remove('is-marquee');
text.style.removeProperty('--marquee-distance');
}
}; };
const setTimelinePosition = function(slide, ratio) { const setTimelinePosition = function(slide, ratio) {
@@ -233,6 +222,94 @@ App.feed = App.feed || {};
timeline.addEventListener('pointercancel', stopScrubbing); timeline.addEventListener('pointercancel', stopScrubbing);
}; };
const flashFeed = function(slide, text) {
const flashEl = slide.querySelector('.feed-flash');
if (!flashEl) return;
flashEl.textContent = text;
flashEl.classList.remove('is-visible');
void flashEl.offsetWidth;
flashEl.classList.add('is-visible');
};
// Wires the controls shared with the standalone fullscreen player (skip
// escalation + double-tap zones, format switching, PiP) onto a reels
// slide, reusing the same App.customPlayer logic so both surfaces behave
// identically. Feed's own timeline/favorite/title and scroll-snap
// slide-to-slide navigation are untouched (see bindTimeline above and
// setActive/onScroll below).
const bindSharedControls = function(slide, video, videoData) {
const cleanups = [];
const escalator = App.customPlayer.createSkipEscalator();
cleanups.push(() => escalator.destroy());
const doSkip = (direction) => {
const amount = App.customPlayer.skip(video, direction, escalator);
flashFeed(slide, `${direction === 'forward' ? '+' : '-'}${amount}s`);
wakeHud();
};
const pipBtn = slide.querySelector('.feed-pip-btn');
if (pipBtn) {
pipBtn.hidden = !App.customPlayer.supportsPiP();
const onClick = async (event) => {
event.stopPropagation();
await App.customPlayer.togglePiP(video);
};
pipBtn.addEventListener('click', onClick);
cleanups.push(() => pipBtn.removeEventListener('click', onClick));
}
cleanups.push(App.customPlayer.bindAutoPiP(video));
const formatBtn = slide.querySelector('.feed-format-btn');
const formatMenu = slide.querySelector('.feed-format-menu');
const onFormatPick = (fmt) => {
slide._formatOverride = fmt;
const t = video.currentTime;
if (isFinite(t) && t > 0) resumeTimes.set(videoData.id, t);
// Tear down the current source (mirrors destroySlidePlayback's
// hls/video reset) before reloading with the new format -- this
// is a live in-place reload, not a fresh never-loaded slide, so
// the old Hls.js instance must be destroyed or it keeps running
// (fetching segments, attached to the same <video>) forever.
if (video._hlsPlayer) {
video._hlsPlayer.destroy();
video._hlsPlayer = null;
}
video._tearingDown = true;
video.pause();
video.removeAttribute('src');
video.load();
slide.classList.remove('is-loaded');
loadSlideSource(slide, videoData, true);
};
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
{ getCurrentUrl: () => slide._activeUrl || '', onOpen: wakeHud });
let destroyFormatMenu = bindFormats();
cleanups.push(() => destroyFormatMenu());
// A slide can go active before its formats have been resolved (feed items
// carry only a page URL until then), which would leave the quality menu
// empty. Playback already runs from that page URL through the proxy, so
// resolve in the background and rebuild the menu once the real qualities
// land -- same as the standalone player does.
if (App.videos && typeof App.videos.ensureFormats === 'function') {
App.videos.ensureFormats(videoData).then((meta) => {
// Bail if the slide was torn down (or rebound) in the meantime.
if (!meta || slide._sharedControlCleanups !== cleanups) return;
destroyFormatMenu();
destroyFormatMenu = bindFormats();
});
}
cleanups.push(App.customPlayer.attachGestures(slide, {
onSingleTap: wakeHud,
onDoubleTapLeft: () => doSkip('back'),
onDoubleTapRight: () => doSkip('forward'),
ignoreSelector: 'button, input, a, .cp-format-menu, .feed-timeline'
}));
slide._sharedControlCleanups = cleanups;
};
const loadSlideSource = function(slide, videoData, autoplay) { const loadSlideSource = function(slide, videoData, autoplay) {
const video = slide.querySelector('.feed-video'); const video = slide.querySelector('.feed-video');
if (!video) return; if (!video) return;
@@ -244,17 +321,36 @@ App.feed = App.feed || {};
} }
return; return;
} }
// A slide whose formats haven't been resolved yet carries only a page
// URL, and handing that to /api/stream makes the backend re-run yt-dlp
// per request (slow, and a hard failure on some sites). Resolve once,
// then load for real -- `_awaitingFormats` keeps a failed resolve from
// looping, so we still fall back to the page URL as a last resort.
const meta = videoData && (videoData.meta || videoData);
const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length);
if (!hasFormats && !slide._awaitingFormats && App.videos && typeof App.videos.ensureFormats === 'function') {
slide._awaitingFormats = true;
App.videos.ensureFormats(videoData).then(() => {
if (slide._videoData === videoData) loadSlideSource(slide, videoData, autoplay);
});
return;
}
slide.classList.add('is-loaded'); slide.classList.add('is-loaded');
const resolved = App.videos.resolveStreamSource(videoData); const resolved = slide._formatOverride
if (!resolved.url) { ? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
: App.videos.resolveStreamSource(videoData);
if (!resolved || !resolved.url) {
// No playable source -- treat exactly like a load failure so the // No playable source -- treat exactly like a load failure so the
// clip is dropped from the queue and the next one takes its place. // clip is dropped from the queue and the next one takes its place.
markSlideFailed(slide); markSlideFailed(slide);
return; return;
} }
// What's actually on screen, so the quality menu can tick it.
slide._activeUrl = resolved.url;
const streamUrl = App.videos.buildStreamUrlFromSource(resolved); const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url); const isHls = App.videos.classifySource(resolved).isHls;
video.muted = state.feedMuted; video.muted = state.feedMuted;
video.preload = 'auto'; video.preload = 'auto';
@@ -328,10 +424,16 @@ App.feed = App.feed || {};
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : ''; const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
const favKey = App.favorites ? App.favorites.getKey(v) : null; const favKey = App.favorites ? App.favorites.getKey(v) : null;
slide.innerHTML = ` slide.innerHTML = `
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async"> <img class="feed-poster" alt="" loading="lazy" decoding="async">
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video> <video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
${liveBadge} ${liveBadge}
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}"></button>` : ''} ${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
<button class="cp-pip-btn feed-pip-btn" type="button" aria-label="Picture in picture" hidden>
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
</button>
<button class="cp-format-btn feed-format-btn" type="button" aria-label="Quality" hidden>HD</button>
<div class="cp-format-menu feed-format-menu" role="menu" hidden></div>
<div class="cp-flash feed-flash" aria-hidden="true"></div>
<div class="feed-info"> <div class="feed-info">
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4> <h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>
${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''} ${uploaderText ? `<p class="feed-uploader">${uploaderText}</p>` : ''}
@@ -344,9 +446,10 @@ App.feed = App.feed || {};
</div> </div>
`; `;
const poster = slide.querySelector('.feed-poster'); const poster = slide.querySelector('.feed-poster');
App.videos.attachNoReferrerRetry(poster); App.videos.attachThumbnail(poster, v.thumb);
const slideVideo = slide.querySelector('.feed-video'); const slideVideo = slide.querySelector('.feed-video');
bindTimeline(slide, slideVideo); bindTimeline(slide, slideVideo);
bindSharedControls(slide, slideVideo, v);
// A media error (bad/expired source, network failure, unsupported codec) // A media error (bad/expired source, network failure, unsupported codec)
// means this clip can't play -- drop it from the queue. Errors fired by // means this clip can't play -- drop it from the queue. Errors fired by
@@ -366,7 +469,7 @@ App.feed = App.feed || {};
const favBtn = slide.querySelector('.feed-fav-btn'); const favBtn = slide.querySelector('.feed-fav-btn');
if (favBtn && App.favorites) { if (favBtn && App.favorites) {
App.favorites.setButtonState(favBtn, App.favorites.getSet().has(favKey)); App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0);
favBtn.addEventListener('click', (event) => { favBtn.addEventListener('click', (event) => {
event.stopPropagation(); event.stopPropagation();
App.favorites.toggle(v); App.favorites.toggle(v);
@@ -388,10 +491,23 @@ App.feed = App.feed || {};
return slide; return slide;
}; };
// Tears down everything a slide holds -- playback (video/hls) plus the
// shared skip/format/PiP/gesture bindings from bindSharedControls -- but
// does not remove it from the DOM or from slidesByIndex (callers differ
// on that: removeSlide always does, reset() removes the whole tree at
// once).
const teardownSlide = function(slide) {
destroySlidePlayback(slide);
if (Array.isArray(slide._sharedControlCleanups)) {
slide._sharedControlCleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
slide._sharedControlCleanups = null;
}
};
const removeSlide = function(index) { const removeSlide = function(index) {
const slide = slidesByIndex.get(index); const slide = slidesByIndex.get(index);
if (!slide) return; if (!slide) return;
destroySlidePlayback(slide); teardownSlide(slide);
slide.remove(); slide.remove();
slidesByIndex.delete(index); slidesByIndex.delete(index);
}; };
@@ -499,7 +615,9 @@ App.feed = App.feed || {};
const bufferAhead = total - 1 - activeIndex; const bufferAhead = total - 1 - activeIndex;
if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12) if (bufferAhead < PREFETCH_PAGES * (state.perPage || 12)
&& state.hasNextPage && !state.isLoading) { && state.hasNextPage && !state.isLoading) {
App.videos.loadVideos(); // The feed is its own reader: the grid's scroll position says
// nothing about whether it needs the next page.
App.videos.loadVideos({ force: true });
} }
}; };
@@ -628,7 +746,7 @@ App.feed = App.feed || {};
App.feed.reset = function() { App.feed.reset = function() {
slidesByIndex.forEach((slide) => { slidesByIndex.forEach((slide) => {
destroySlidePlayback(slide); teardownSlide(slide);
slide.remove(); slide.remove();
}); });
slidesByIndex.clear(); slidesByIndex.clear();
@@ -653,7 +771,12 @@ App.feed = App.feed || {};
state.feedOpen = true; state.feedOpen = true;
if (App.player && typeof App.player.close === 'function') { if (App.player && typeof App.player.close === 'function') {
App.player.close(); // fromPopState: true suppresses the player's own history.back()
// -- this is an incidental "make sure it's closed" call when
// switching to Reels view, not the user pressing the player's
// close button, so it must not silently consume a back-button
// entry out from under real browser navigation.
App.player.close({ fromPopState: true });
} }
container.classList.add('open'); container.classList.add('open');

View File

@@ -0,0 +1,79 @@
window.App = window.App || {};
App.hottubBackup = App.hottubBackup || {};
// Reads a Hot Tub app backup (an exported SQLite database) and turns the videos
// it has flagged as favorites into this client's favorites.
//
// The app and this client don't agree on identity: the app keys a video by a
// hash it computes locally, while the server -- and so this client -- keys it by
// something like "reddit-1rdudss". So an imported favorite is matched to an
// existing one by URL, and carries no id of its own; see App.favorites.mergeImported.
(function() {
// The app stores a comma-separated set here ("favorite", "recent", ...).
// It also keeps a `favoriteDate` on rows it no longer flags -- a leftover
// from unfavoriting -- so the flag, not the date, is what counts.
const FAVORITE_FLAG = 'favorite';
// Only what a favorite needs. Skipping the rest matters: `allFormats` alone
// is kilobytes of resolved-format JSON per row, and it is exactly the kind
// of thing this client must not store -- those URLs are signed and expire
// (see App.favorites.normalize).
const COLUMNS = ['url', 'title', 'thumb', 'duration', 'uploader', 'flags', 'favoriteDate'];
// The app writes a local-time stamp with no zone ("2025-04-25T20:26:02.144").
// Read it as local time (which is what it was) and keep it as an instant, so
// imported favorites sort against ones saved here. Unparseable or missing
// dates fall back to now rather than to 1970, which would bury them.
const toIsoDate = function(value) {
const parsed = Date.parse(value || '');
return isNaN(parsed) ? new Date().toISOString() : new Date(parsed).toISOString();
};
const hasFavoriteFlag = function(flags) {
if (!flags) return false;
return String(flags).split(',').some((flag) => flag.trim().toLowerCase() === FAVORITE_FLAG);
};
// Newest first, matching how favorites are ordered when added by hand.
const byNewest = function(a, b) {
return String(b.favoriteDate || '').localeCompare(String(a.favoriteDate || ''));
};
App.hottubBackup.readFavorites = function(buffer) {
const db = App.sqlite.open(buffer);
if (db.tableNames().indexOf('video_details') < 0) {
throw new Error('This database has no video_details table -- is it a Hot Tub backup?');
}
const rows = db.readTable('video_details', { columns: COLUMNS });
return rows
.filter((row) => row.url && hasFavoriteFlag(row.flags))
.sort(byNewest)
.map((row) => ({
// No id: the app's own is meaningless to this client, and the
// URL is what both sides agree on.
key: row.url,
id: null,
url: row.url,
title: row.title || '',
thumb: row.thumb || '',
channel: '',
uploader: row.uploader || '',
duration: Number(row.duration) || 0,
isLive: false,
favoriteDate: toIsoDate(row.favoriteDate)
}));
};
App.hottubBackup.readFile = function(file) {
return file.arrayBuffer().then((buffer) => App.hottubBackup.readFavorites(buffer));
};
// Reads the file and merges what it finds. Resolves to the merge summary
// ({found, added, skipped, total}) so the caller can report it.
App.hottubBackup.importFile = function(file) {
return App.hottubBackup.readFile(file).then((entries) => {
const result = App.favorites.mergeImported(entries);
return Object.assign({ found: entries.length }, result);
});
};
})();

View File

@@ -8,6 +8,9 @@ window.App = window.App || {};
App.ui.applyPreferredQuality(); App.ui.applyPreferredQuality();
App.ui.applyFeedEndBehavior(); App.ui.applyFeedEndBehavior();
App.ui.applyDensity(); App.ui.applyDensity();
// Set the text-size CSS variable before the first pack so initial card
// heights are measured at the user's chosen size.
document.documentElement.style.setProperty('--card-font-scale', App.storage.getFontScale());
App.ui.renderMenu(); App.ui.renderMenu();
App.favorites.renderBar(); App.favorites.renderBar();
App.ui.bindGlobalHandlers(); App.ui.bindGlobalHandlers();
@@ -17,7 +20,7 @@ window.App = window.App || {};
const loadMoreBtn = document.getElementById('load-more-btn'); const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) { if (loadMoreBtn) {
loadMoreBtn.onclick = () => { loadMoreBtn.onclick = () => {
App.videos.loadVideos(); App.videos.loadVideos({ force: true });
}; };
} }

51
frontend/js/marquee.js Normal file
View File

@@ -0,0 +1,51 @@
window.App = window.App || {};
App.marquee = App.marquee || {};
// A title is always one line. When it doesn't fit its box it scrolls sideways
// instead of wrapping or being silently cut off.
//
// Four surfaces show a title that way -- the grid card, the favorites bar card,
// the fullscreen player, the reels slide -- and they must scroll at the same
// speed to look like one app, so the measurement lives here rather than being
// written out again next to each of them. Whether a given title is *currently*
// scrolling is the caller's business (see the `is-title-active` handling in
// videos.js and favorites.js): a whole grid of marching text is unreadable, so
// most callers animate only the title the reader is actually looking at.
(function() {
// Drive the duration off the distance so every title scrolls at the same
// gentle rate rather than a fixed duration, which made longer titles whip
// past. The floor keeps short ones from snapping.
const SPEED_PX_PER_SEC = 28;
const MIN_DURATION_S = 6;
// Sub-pixel rounding isn't overflow worth animating.
const OVERFLOW_SLACK_PX = 4;
// Trailing space so the last word clears the edge before it wraps around.
const TAIL_GAP_PX = 12;
// Measures `text` inside `wrap` and prepares the animation: sets
// --marquee-distance / --marquee-duration on `text`, and marks `wrap` with
// `has-marquee` so CSS can decide what to do about it. Returns whether the
// title overflows -- callers use that to skip the bookkeeping (scroll
// observers, hover handlers) that only scrolling titles need.
//
// Reads layout, so call it when the element is in the document and visible;
// a hidden element measures as zero-width and reports no overflow.
App.marquee.measure = function(wrap, text) {
if (!wrap || !text) return false;
const overflow = text.scrollWidth - wrap.clientWidth;
if (overflow <= OVERFLOW_SLACK_PX) {
wrap.classList.remove('has-marquee');
text.style.removeProperty('--marquee-distance');
text.style.removeProperty('--marquee-duration');
return false;
}
const distance = overflow + TAIL_GAP_PX;
const duration = Math.max(MIN_DURATION_S, distance / SPEED_PX_PER_SEC);
text.style.setProperty('--marquee-distance', `${distance}px`);
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
wrap.classList.add('has-marquee');
return true;
};
})();

File diff suppressed because it is too large Load Diff

BIN
frontend/js/sqlite.js Normal file

Binary file not shown.

View File

@@ -10,10 +10,6 @@ App.state = {
hlsPlayer: null, hlsPlayer: null,
currentLoadController: null, currentLoadController: null,
errorToastTimer: null, errorToastTimer: null,
playerMode: 'modal',
playerHome: null,
onFullscreenChange: null,
onWebkitEndFullscreen: null,
loadedVideos: [], loadedVideos: [],
feedOpen: false, feedOpen: false,
feedMuted: true, feedMuted: true,
@@ -26,6 +22,7 @@ App.state = {
App.constants = { App.constants = {
FAVORITES_KEY: 'favorites', FAVORITES_KEY: 'favorites',
FAVORITES_VISIBILITY_KEY: 'favoritesVisible', FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
FAVORITES_SORT_KEY: 'favoritesSort',
PREFERRED_QUALITY_KEY: 'preferredQuality', PREFERRED_QUALITY_KEY: 'preferredQuality',
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior' FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
}; };

View File

@@ -57,6 +57,30 @@ App.session = App.session || {};
localStorage.setItem('density', nextDensity === 'compact' ? 'compact' : 'comfortable'); localStorage.setItem('density', nextDensity === 'compact' ? 'compact' : 'comfortable');
}; };
// User-tunable card width / text size multipliers (default 1.0). Clamped so a
// stale or hand-edited value can never break the layout.
const clampScale = function(value, min, max, fallback) {
const n = parseFloat(value);
if (!isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
};
App.storage.getCardScale = function() {
return clampScale(localStorage.getItem('cardScale'), 0.7, 1.5, 1);
};
App.storage.setCardScale = function(next) {
localStorage.setItem('cardScale', clampScale(next, 0.7, 1.5, 1));
};
App.storage.getFontScale = function() {
return clampScale(localStorage.getItem('fontScale'), 0.8, 1.4, 1);
};
App.storage.setFontScale = function(next) {
localStorage.setItem('fontScale', clampScale(next, 0.8, 1.4, 1));
};
App.storage.getServerEntries = function() { App.storage.getServerEntries = function() {
const config = App.storage.getConfig(); const config = App.storage.getConfig();
if (!config.servers || !Array.isArray(config.servers)) return []; if (!config.servers || !Array.isArray(config.servers)) return [];

View File

@@ -28,6 +28,29 @@ App.ui = App.ui || {};
if (select) select.value = density; if (select) select.value = density;
}; };
// Card Size: re-packs the virtual grid (column count derives from the scaled
// minimum card width in videos.js).
App.ui.applyCardScale = function() {
const scale = App.storage.getCardScale();
const range = document.getElementById('card-size-range');
if (range) range.value = scale;
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
App.virtualGrid.relayout();
}
};
// Text Size: drives the --card-font-scale CSS variable; a re-pack follows so
// card heights account for the new text size.
App.ui.applyFontScale = function() {
const scale = App.storage.getFontScale();
document.documentElement.style.setProperty('--card-font-scale', scale);
const range = document.getElementById('text-size-range');
if (range) range.value = scale;
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
App.virtualGrid.relayout();
}
};
// Toast helper for playback + network errors. // Toast helper for playback + network errors.
App.ui.showError = function(message) { App.ui.showError = function(message) {
const toast = document.getElementById('error-toast'); const toast = document.getElementById('error-toast');
@@ -43,25 +66,23 @@ App.ui = App.ui || {};
}, 4000); }, 4000);
}; };
App.ui.showInfo = function(video) { // Which video the panel is currently showing, so a slow resolve that lands
const modal = document.getElementById('info-modal'); // after the user moved on doesn't redraw someone else's panel.
if (!modal) return; let infoVideo = null;
const title = document.getElementById('info-title');
const list = document.getElementById('info-list');
const empty = document.getElementById('info-empty');
const data = video && video.meta ? video.meta : video; const appendInfoHeading = function(list, label) {
const titleText = data && data.title ? data.title : 'Video Info'; const heading = document.createElement('div');
if (title) title.textContent = titleText; heading.className = 'info-section';
heading.textContent = label;
list.appendChild(heading);
};
if (list) { // One row per field, whatever the field is. Objects and arrays are printed
list.innerHTML = ""; // as JSON rather than summarised: the panel is the place to see exactly what
} // the server said, so nothing is dropped or abbreviated here.
const appendInfoRows = function(list, data) {
let hasRows = false; let count = 0;
if (data && typeof data === 'object') { Object.entries(data || {}).forEach(([key, value]) => {
Object.entries(data).forEach(([key, value]) => {
if (!list) return;
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'info-row'; row.className = 'info-row';
@@ -83,21 +104,81 @@ App.ui = App.ui || {};
row.appendChild(label); row.appendChild(label);
row.appendChild(valueNode); row.appendChild(valueNode);
list.appendChild(row); list.appendChild(row);
hasRows = true; count++;
}); });
return count;
};
// Shows every field the client holds for a video: the listing item's own
// (id, title, uploader, duration, tags, ...) and then the extractor's, which
// arrive separately. It used to show `video.meta` *instead of* the item once
// one had been resolved, which silently hid everything the listing knew the
// moment a card had been hovered.
// `options.info` is the full extractor payload (App.videos.fetchFullInfo);
// `options.pending` notes that it's still on its way.
App.ui.showInfo = function(video, options) {
const modal = document.getElementById('info-modal');
if (!modal) return;
const opts = options || {};
const title = document.getElementById('info-title');
const list = document.getElementById('info-list');
const empty = document.getElementById('info-empty');
const item = (video && typeof video === 'object') ? video : {};
// `meta` is the trimmed playback payload; the full extractor info is a
// superset of it, so only one of the two is ever shown.
const resolved = opts.info || item.meta || null;
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
let rows = 0;
if (list) {
list.innerHTML = "";
// `meta` gets its own section below rather than a row of JSON.
const own = Object.assign({}, item);
delete own.meta;
rows += appendInfoRows(list, own);
if (resolved && typeof resolved === 'object') {
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
rows += appendInfoRows(list, resolved);
}
if (opts.pending) {
const pending = document.createElement('div');
pending.className = 'info-pending';
pending.textContent = 'Resolving full metadata…';
list.appendChild(pending);
}
} }
if (empty) { if (empty) {
empty.style.display = hasRows ? 'none' : 'block'; empty.style.display = rows ? 'none' : 'block';
} }
modal.classList.add('open'); modal.classList.add('open');
modal.setAttribute('aria-hidden', 'false'); modal.setAttribute('aria-hidden', 'false');
}; };
// Opens the panel on what the client already has, then redraws it with the
// extractor's full payload once that lands. Resolution runs yt-dlp against
// the source site and can take seconds; there's no reason to stare at
// nothing (or at a spinner) while it does.
App.ui.openInfo = function(video) {
infoVideo = video;
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
App.ui.showInfo(video, { pending: canResolve });
if (!canResolve) return;
App.videos.fetchFullInfo(video).then((info) => {
if (infoVideo !== video) return; // the panel moved on, or closed
App.ui.showInfo(video, { info: info });
});
};
App.ui.closeInfo = function() { App.ui.closeInfo = function() {
const modal = document.getElementById('info-modal'); const modal = document.getElementById('info-modal');
if (!modal) return; if (!modal) return;
infoVideo = null;
modal.classList.remove('open'); modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true'); modal.setAttribute('aria-hidden', 'true');
}; };
@@ -305,6 +386,24 @@ App.ui = App.ui || {};
}; };
} }
const cardSizeRange = document.getElementById('card-size-range');
if (cardSizeRange) {
cardSizeRange.value = App.storage.getCardScale();
cardSizeRange.oninput = () => {
App.storage.setCardScale(cardSizeRange.value);
App.ui.applyCardScale();
};
}
const textSizeRange = document.getElementById('text-size-range');
if (textSizeRange) {
textSizeRange.value = App.storage.getFontScale();
textSizeRange.oninput = () => {
App.storage.setFontScale(textSizeRange.value);
App.ui.applyFontScale();
};
}
const feedEndSelect = document.getElementById('feed-end-select'); const feedEndSelect = document.getElementById('feed-end-select');
if (feedEndSelect) { if (feedEndSelect) {
feedEndSelect.value = App.storage.getFeedEndBehavior(); feedEndSelect.value = App.storage.getFeedEndBehavior();
@@ -420,6 +519,13 @@ App.ui = App.ui || {};
if (reloadChannelBtn) { if (reloadChannelBtn) {
reloadChannelBtn.onclick = () => { reloadChannelBtn.onclick = () => {
// Refresh means "give me the current everything": the videos
// below, and the app itself. The version check runs in the
// background and only acts if the deployed assets actually
// differ from what this tab is running.
if (App.version && typeof App.version.checkNow === 'function') {
App.version.checkNow();
}
App.videos.resetAndReload(); App.videos.resetAndReload();
}; };
} }
@@ -568,10 +674,77 @@ App.ui = App.ui || {};
}; };
// Expose inline handlers + keyboard shortcuts. // Expose inline handlers + keyboard shortcuts.
// Settings -> Hot Tub Backup: pick an exported database and merge the
// favorites out of it. Bound once (unlike the controls in renderMenu, which
// are re-assigned on every render) because a file input mid-read must not
// have its handler swapped underneath it.
App.ui.bindBackupImport = function() {
const button = document.getElementById('import-favorites-btn');
const input = document.getElementById('import-favorites-file');
const status = document.getElementById('import-favorites-status');
if (!button || !input) return;
const say = (message) => { if (status) status.textContent = message; };
button.addEventListener('click', () => {
// Cleared first so picking the same file twice still fires change.
input.value = '';
input.click();
});
input.addEventListener('change', () => {
const file = input.files && input.files[0];
if (!file) return;
button.disabled = true;
say('Reading backup…');
App.hottubBackup.importFile(file).then((result) => {
if (!result.found) {
say('No favorites found in that backup.');
} else if (!result.added) {
say(`Nothing new: all ${result.found} favorites in that backup are already saved.`);
} else {
const plural = result.added === 1 ? 'favorite' : 'favorites';
const already = result.skipped ? ` ${result.skipped} were already saved.` : '';
say(`Imported ${result.added} ${plural}.${already}`);
}
}).catch((err) => {
say('Could not read that file.');
App.ui.showError((err && err.message) || 'Could not read that backup.');
}).then(() => {
button.disabled = false;
});
});
};
// Favorites bar header: the sort order (which applies to the bar and the
// favorites grid alike) and the toggle into that grid.
App.ui.bindFavoritesControls = function() {
const select = document.getElementById('favorites-sort');
const button = document.getElementById('favorites-browse-btn');
if (select && !select.options.length) {
App.favorites.SORTS.forEach((sort) => {
const option = document.createElement('option');
option.value = sort.id;
option.textContent = sort.label;
select.appendChild(option);
});
select.value = App.favorites.getSort();
select.addEventListener('change', () => App.favoritesView.applySort(select.value));
}
if (button) {
button.addEventListener('click', () => App.favoritesView.toggle());
}
App.favoritesView.syncControls();
};
App.ui.bindGlobalHandlers = function() { App.ui.bindGlobalHandlers = function() {
App.ui.bindBackupImport();
App.ui.bindFavoritesControls();
window.toggleDrawer = App.ui.toggleDrawer; window.toggleDrawer = App.ui.toggleDrawer;
window.closeDrawers = App.ui.closeDrawers; window.closeDrawers = App.ui.closeDrawers;
window.closePlayer = App.player.close;
window.handleSearch = App.videos.handleSearch; window.handleSearch = App.videos.handleSearch;
const modeToggleBtn = document.getElementById('mode-toggle-btn'); const modeToggleBtn = document.getElementById('mode-toggle-btn');

View File

@@ -49,15 +49,16 @@ App.version = App.version || {};
return changed; return changed;
} }
// A reload is "safe" when the user isn't mid-playback: no open video modal, // A reload is "safe" when the user isn't mid-playback: no open custom
// no active reels feed, and no playing <video>. App state survives a reload // player, no active reels feed, and no playing <video>. App state survives
// because it is restored from localStorage on boot. // a reload because it is restored from localStorage on boot.
function isSafeToReload() { function isSafeToReload() {
if (App.state && App.state.feedOpen) return false; if (App.state && App.state.feedOpen) return false;
const modal = document.getElementById('video-modal'); const player = document.getElementById('custom-player');
if (modal && modal.style.display && modal.style.display !== 'none') return false; if (player && player.classList.contains('open')) {
const player = document.getElementById('player'); const video = player.querySelector('.cp-video');
if (player && !player.paused && !player.ended) return false; if (video && !video.paused && !video.ended) return false;
}
return true; return true;
} }
@@ -113,6 +114,21 @@ App.version = App.version || {};
} }
} }
// Same check the poller runs, on demand: the top-bar refresh button asks for
// it so a tab left open across a deploy picks the new build up right then,
// rather than up to POLL_INTERVAL_MS later. Changed CSS hot-swaps; changed
// JS/HTML reloads as soon as that won't interrupt playback.
App.version.checkNow = function() {
if (!baseline) {
// start() never got a manifest (endpoint down, or it hasn't run
// yet). Adopt whatever the server reports now so there's something
// to diff against next time -- there's no baseline to compare this
// one against, so nothing can be concluded from it today.
return fetchVersion().then((latest) => { baseline = latest; }).catch(() => {});
}
return check();
};
App.version.start = async function() { App.version.start = async function() {
try { try {
baseline = await fetchVersion(); baseline = await fetchVersion();
@@ -128,11 +144,12 @@ App.version = App.version || {};
check(); check();
} }
}); });
// Re-attempt a deferred reload whenever a video finishes/pauses. // Re-attempt a deferred reload whenever a video finishes/pauses. The
const player = document.getElementById('player'); // custom player's <video> is torn down and rebuilt on every open(), so
if (player) { // bind on the capture phase at the document level instead of to a
player.addEventListener('pause', tryReloadWhenSafe); // specific element (media events don't bubble, but capture still sees
player.addEventListener('ended', tryReloadWhenSafe); // them on ancestors).
} document.addEventListener('pause', tryReloadWhenSafe, true);
document.addEventListener('ended', tryReloadWhenSafe, true);
}; };
})(); })();

File diff suppressed because it is too large Load Diff

1
media_srv2.log Normal file
View File

@@ -0,0 +1 @@
/bin/bash: line 1: cd: too many arguments