Compare commits
36 Commits
a251b274db
...
thumbnail-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b719b2ea | ||
|
|
e2632c962d | ||
|
|
0009574b77 | ||
|
|
d4ed9dce5d | ||
|
|
c54d0889c1 | ||
|
|
b48d7aa161 | ||
|
|
52d7802491 | ||
|
|
d508263946 | ||
|
|
59f7c33ebd | ||
|
|
acfffb3a91 | ||
|
|
0f7e27fd77 | ||
|
|
6631447acc | ||
|
|
0f30480af4 | ||
|
|
a9893068cd | ||
|
|
d7086ead27 | ||
|
|
25dad88ed9 | ||
|
|
b6b17b1f52 | ||
|
|
7207e36510 | ||
|
|
5d739bec12 | ||
|
|
2e6e74b959 | ||
|
|
1d0b435e87 | ||
|
|
138c3224de | ||
|
|
382a637b95 | ||
|
|
455e5cf8d8 | ||
|
|
b931765c06 | ||
|
|
e3eeaacc53 | ||
|
|
48a15759fc | ||
|
|
9fe7511b4d | ||
|
|
17f3161d55 | ||
|
|
5aa95e90d4 | ||
|
|
f5bb33521e | ||
|
|
b9ff61244c | ||
|
|
55828c9726 | ||
|
|
d6865d7c35 | ||
|
|
80476a8a42 | ||
|
|
785b991d01 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
*/__pycache__/*
|
*/__pycache__/*
|
||||||
.tmp
|
.tmp
|
||||||
frontend/dist/*
|
frontend/dist/*
|
||||||
|
.playwright-mcp/*
|
||||||
|
|||||||
520
backend/main.py
520
backend/main.py
@@ -10,7 +10,10 @@ 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 hashlib
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
# Browser to impersonate at the TLS/HTTP layer. Some origins (e.g. the
|
# Browser to impersonate at the TLS/HTTP layer. Some origins (e.g. the
|
||||||
@@ -18,24 +21,139 @@ 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.
|
|
||||||
STREAM_RESERVED_PARAMS = {'url'}
|
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
|
||||||
|
# `live` is purely a playback hint and must not leak upstream as a header. `full`
|
||||||
|
# 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'}
|
||||||
|
# Headers curl_cffi sets coherently for the impersonated browser. Forwarding the
|
||||||
|
# client's (or extractor's) own values for these would contradict the spoofed TLS
|
||||||
|
# fingerprint and defeat impersonation, so they are never relayed upstream.
|
||||||
|
STREAM_IMPERSONATION_MANAGED_HEADERS = {
|
||||||
|
'user-agent', 'accept', 'accept-encoding', 'accept-language',
|
||||||
|
'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.
|
||||||
@@ -163,6 +281,175 @@ def videos_proxy():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
# Cache of yt-dlp metadata resolutions so the on-demand probe endpoint doesn't
|
||||||
|
# re-extract the same video on every hover/scroll. Signed media URLs expire, so
|
||||||
|
# entries are intentionally short-lived.
|
||||||
|
RESOLVE_CACHE_TTL = 300
|
||||||
|
_resolve_cache = {}
|
||||||
|
_resolve_cache_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Per-format fields the frontend needs to rank formats and build stream/probe
|
||||||
|
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
|
||||||
|
# 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',
|
||||||
|
'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
|
||||||
|
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
|
||||||
|
# by tube.perverzija.com). The player page declares its HLS playlist URL as
|
||||||
|
# `m3u8_loader_url + video_id`, so we scrape the page for the embed iframe, then
|
||||||
|
# read those two variables out of the player to reconstruct the stream URL.
|
||||||
|
_EMBED_IFRAME_RE = re.compile(r'''<iframe[^>]+src=["']([^"']+)''', re.I)
|
||||||
|
_EMBED_LOADER_RE = re.compile(r'''m3u8_loader_url\s*=\s*[`'"]([^`'"]+)[`'"]''')
|
||||||
|
_EMBED_VIDEOID_RE = re.compile(r'''video_id\s*=\s*[`'"]([^`'"]+)[`'"]''')
|
||||||
|
|
||||||
|
def resolve_unsupported_embed(page_url):
|
||||||
|
"""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
|
||||||
|
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:
|
||||||
|
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
|
||||||
|
embed_url = None
|
||||||
|
for src in _EMBED_IFRAME_RE.findall(page.text):
|
||||||
|
candidate = urljoin(page_url, src)
|
||||||
|
if '/player/' in candidate or 'index.php?data=' in candidate:
|
||||||
|
embed_url = candidate
|
||||||
|
break
|
||||||
|
if not embed_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
player = sess.get(embed_url, headers={'Referer': page_url}, timeout=15)
|
||||||
|
loader = _EMBED_LOADER_RE.search(player.text)
|
||||||
|
video_id = _EMBED_VIDEOID_RE.search(player.text)
|
||||||
|
if not (loader and video_id):
|
||||||
|
return None
|
||||||
|
stream_url = loader.group(1) + video_id.group(1)
|
||||||
|
|
||||||
|
parsed = urllib.parse.urlparse(embed_url)
|
||||||
|
referer = f"{parsed.scheme}://{parsed.netloc}/"
|
||||||
|
headers = {'Referer': referer}
|
||||||
|
return {
|
||||||
|
'url': stream_url,
|
||||||
|
'is_live': False,
|
||||||
|
'http_headers': headers,
|
||||||
|
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
_discard_session(sess)
|
||||||
|
sess = None
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if sess is not None:
|
||||||
|
_return_session(sess)
|
||||||
|
|
||||||
|
@app.route('/api/resolve', methods=['POST', 'GET'])
|
||||||
|
def resolve_video():
|
||||||
|
"""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
|
||||||
|
into view) to learn the real media URLs so it can background-probe them for
|
||||||
|
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':
|
||||||
|
source = request.json or {}
|
||||||
|
video_url = source.get('url')
|
||||||
|
else:
|
||||||
|
source = request.args
|
||||||
|
video_url = request.args.get('url')
|
||||||
|
|
||||||
|
if not video_url:
|
||||||
|
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()
|
||||||
|
with _resolve_cache_lock:
|
||||||
|
cached = _resolve_cache.get(video_url)
|
||||||
|
if cached and cached[0] > now:
|
||||||
|
return jsonify(view_of(cached[1]))
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
'quiet': True,
|
||||||
|
'no_warnings': True,
|
||||||
|
'skip_download': True,
|
||||||
|
# Match /api/stream so the resolved formats reflect what playback will
|
||||||
|
# actually fetch from fingerprinting origins.
|
||||||
|
'impersonate': ImpersonateTarget.from_str(IMPERSONATE_TARGET),
|
||||||
|
}
|
||||||
|
passthrough_headers = collect_passthrough_headers(source)
|
||||||
|
if passthrough_headers:
|
||||||
|
ydl_opts['http_headers'] = passthrough_headers
|
||||||
|
|
||||||
|
try:
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
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:
|
||||||
|
# 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
|
||||||
|
# stream, and otherwise we return empty formats so playback falls back to
|
||||||
|
# the proxy.
|
||||||
|
app.logger.info("[resolve] yt-dlp could not extract %s: %s", video_url, e)
|
||||||
|
info = None
|
||||||
|
|
||||||
|
# Fall back to scraping iframe-embedded JS players yt-dlp doesn't support.
|
||||||
|
if not (info and (info.get('formats') or info.get('url'))):
|
||||||
|
embed = resolve_unsupported_embed(video_url)
|
||||||
|
if embed:
|
||||||
|
info = embed
|
||||||
|
|
||||||
|
# The extraction is cached whole, and each caller is served the view it
|
||||||
|
# asked for. A failed extraction (info is None) is cached the same way, so a
|
||||||
|
# video that can't be resolved is attempted once per TTL rather than on
|
||||||
|
# every hover.
|
||||||
|
with _resolve_cache_lock:
|
||||||
|
# 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]:
|
||||||
|
_resolve_cache.pop(key, None)
|
||||||
|
_resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, info)
|
||||||
|
|
||||||
|
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():
|
||||||
image_url = request.args.get('url')
|
image_url = request.args.get('url')
|
||||||
@@ -208,14 +495,102 @@ 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():
|
||||||
return send_from_directory(app.static_folder, 'index.html')
|
"""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')
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
# --- Frontend asset version tracking -------------------------------------
|
||||||
|
# The client polls /api/version and, when a tracked file's content hash
|
||||||
|
# changes, hot-swaps CSS in place or reloads the page. This lets a deploy
|
||||||
|
# reach already-open tabs without a manual refresh.
|
||||||
|
_FRONTEND_DIR = os.path.abspath(app.static_folder)
|
||||||
|
_VERSION_EXTS = ('.html', '.css', '.js')
|
||||||
|
_version_cache = {'mtime': None, 'payload': None}
|
||||||
|
_version_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_frontend_files():
|
||||||
|
"""Map served relative paths -> absolute paths for tracked frontend files."""
|
||||||
|
files = {}
|
||||||
|
for root, _dirs, names in os.walk(_FRONTEND_DIR):
|
||||||
|
for name in names:
|
||||||
|
if os.path.splitext(name)[1].lower() not in _VERSION_EXTS:
|
||||||
|
continue
|
||||||
|
path = os.path.join(root, name)
|
||||||
|
rel = os.path.relpath(path, _FRONTEND_DIR).replace(os.sep, '/')
|
||||||
|
files[rel] = path
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_version_payload(files):
|
||||||
|
"""Hash each tracked file's contents plus a combined version fingerprint."""
|
||||||
|
file_hashes = {}
|
||||||
|
combined = hashlib.md5()
|
||||||
|
for rel in sorted(files):
|
||||||
|
try:
|
||||||
|
with open(files[rel], 'rb') as fh:
|
||||||
|
digest = hashlib.md5(fh.read()).hexdigest()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
file_hashes[rel] = digest
|
||||||
|
combined.update(rel.encode('utf-8'))
|
||||||
|
combined.update(digest.encode('utf-8'))
|
||||||
|
return {'version': combined.hexdigest(), 'files': file_hashes}
|
||||||
|
|
||||||
|
|
||||||
|
def _version_payload():
|
||||||
|
files = _scan_frontend_files()
|
||||||
|
# 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.
|
||||||
|
try:
|
||||||
|
latest_mtime = max((os.path.getmtime(p) for p in files.values()), default=0)
|
||||||
|
except OSError:
|
||||||
|
latest_mtime = 0
|
||||||
|
with _version_lock:
|
||||||
|
if _version_cache['mtime'] != latest_mtime or _version_cache['payload'] is None:
|
||||||
|
_version_cache['payload'] = _compute_version_payload(files)
|
||||||
|
_version_cache['mtime'] = latest_mtime
|
||||||
|
return _version_cache['payload']
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/version', methods=['GET'])
|
||||||
|
def frontend_version():
|
||||||
|
resp = jsonify(_version_payload())
|
||||||
|
resp.headers['Cache-Control'] = 'no-store'
|
||||||
|
return resp
|
||||||
|
|
||||||
@app.route('/api/stream', methods=['POST', 'GET', 'HEAD'])
|
@app.route('/api/stream', methods=['POST', 'GET', 'HEAD'])
|
||||||
def stream_video():
|
def stream_video():
|
||||||
# Note: <video> tags perform GET. To support your POST requirement,
|
# Note: <video> tags perform GET. To support your POST requirement,
|
||||||
@@ -241,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'):
|
||||||
@@ -270,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):
|
||||||
@@ -300,6 +682,17 @@ def stream_video():
|
|||||||
headers['Cookie'] = request.headers['Cookie']
|
headers['Cookie'] = request.headers['Cookie']
|
||||||
dbg("forwarding cookies")
|
dbg("forwarding cookies")
|
||||||
|
|
||||||
|
# Relay the per-format headers (e.g. Cookie) the frontend forwarded as
|
||||||
|
# query params so cookie/token-authorized origins serve the media.
|
||||||
|
# Referer is already set above, and impersonation-managed headers are left
|
||||||
|
# to curl_cffi to keep the request coherent with the spoofed fingerprint.
|
||||||
|
for key, value in collect_passthrough_headers(request.args).items():
|
||||||
|
lower = key.lower()
|
||||||
|
if lower == 'referer' or lower in STREAM_IMPERSONATION_MANAGED_HEADERS:
|
||||||
|
continue
|
||||||
|
if value:
|
||||||
|
headers[key] = value
|
||||||
|
|
||||||
# Remove keys with None values
|
# Remove keys with None values
|
||||||
return {k: v for k, v in headers.items() if v}
|
return {k: v for k, v in headers.items() if v}
|
||||||
|
|
||||||
@@ -355,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
|
||||||
@@ -364,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')}")
|
||||||
|
|
||||||
@@ -402,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:
|
||||||
@@ -426,10 +865,27 @@ def stream_video():
|
|||||||
except LookupError:
|
except LookupError:
|
||||||
return body_bytes.decode("utf-8", errors="replace")
|
return body_bytes.decode("utf-8", errors="replace")
|
||||||
|
|
||||||
|
def passthrough_param_suffix():
|
||||||
|
# The relayed headers (e.g. Cookie) the upstream needs for authorization,
|
||||||
|
# encoded as &Name=value so they ride along on every proxied child URL
|
||||||
|
# (variant playlists, segments). Referer is appended separately by each
|
||||||
|
# rewriter; impersonation-managed headers stay with curl_cffi.
|
||||||
|
parts = []
|
||||||
|
for key, value in collect_passthrough_headers(request.args).items():
|
||||||
|
lower = key.lower()
|
||||||
|
if lower == 'referer' or lower in STREAM_IMPERSONATION_MANAGED_HEADERS:
|
||||||
|
continue
|
||||||
|
if not value:
|
||||||
|
continue
|
||||||
|
parts.append(f"&{urllib.parse.quote(key)}={urllib.parse.quote(str(value))}")
|
||||||
|
return ''.join(parts)
|
||||||
|
|
||||||
def rewrite_hls_playlist(body_text, base_url, referer):
|
def rewrite_hls_playlist(body_text, base_url, referer):
|
||||||
|
extra = passthrough_param_suffix()
|
||||||
|
|
||||||
def proxied_url(target):
|
def proxied_url(target):
|
||||||
absolute = urljoin(base_url, target)
|
absolute = urljoin(base_url, target)
|
||||||
return f"/api/stream?url={urllib.parse.quote(absolute, safe='')}&referer={urllib.parse.quote(referer, safe='')}"
|
return f"/api/stream?url={urllib.parse.quote(absolute, safe='')}&referer={urllib.parse.quote(referer, safe='')}{extra}"
|
||||||
|
|
||||||
lines = body_text.splitlines()
|
lines = body_text.splitlines()
|
||||||
rewritten = []
|
rewritten = []
|
||||||
@@ -460,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:
|
||||||
@@ -590,9 +1046,11 @@ def stream_video():
|
|||||||
if not video_fmts:
|
if not video_fmts:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
extra = passthrough_param_suffix()
|
||||||
|
|
||||||
def proxied(url):
|
def proxied(url):
|
||||||
return (f"/api/stream?url={urllib.parse.quote(url, safe='')}"
|
return (f"/api/stream?url={urllib.parse.quote(url, safe='')}"
|
||||||
f"&referer={urllib.parse.quote(referer, safe='')}")
|
f"&referer={urllib.parse.quote(referer, safe='')}{extra}")
|
||||||
|
|
||||||
lines = ['#EXTM3U', '#EXT-X-VERSION:3']
|
lines = ['#EXTM3U', '#EXT-X-VERSION:3']
|
||||||
|
|
||||||
@@ -664,7 +1122,19 @@ def stream_video():
|
|||||||
|
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
# Extract the info
|
# Extract the info
|
||||||
info = ydl.extract_info(video_url, download=False)
|
try:
|
||||||
|
info = ydl.extract_info(video_url, download=False)
|
||||||
|
except Exception as ydl_err:
|
||||||
|
# yt-dlp can't extract iframe-embedded JS players; scrape the
|
||||||
|
# embed for its HLS playlist and proxy that directly instead.
|
||||||
|
embed = resolve_unsupported_embed(video_url)
|
||||||
|
if not embed:
|
||||||
|
raise
|
||||||
|
dbg(f"embed fallback resolved {video_url} -> {embed['url']}")
|
||||||
|
if request.method == 'HEAD':
|
||||||
|
return Response("", status=200, content_type='application/vnd.apple.mpegurl')
|
||||||
|
return proxy_hls_playlist(embed['url'], embed['http_headers'].get('Referer'),
|
||||||
|
upstream_headers=embed['http_headers'])
|
||||||
dbg(f"yt_dlp extractor={info.get('extractor')} protocol={info.get('protocol')}")
|
dbg(f"yt_dlp extractor={info.get('extractor')} protocol={info.get('protocol')}")
|
||||||
|
|
||||||
# Try to get the URL from the info dict (works for progressive downloads)
|
# Try to get the URL from the info dict (works for progressive downloads)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,10 +7,11 @@
|
|||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
<link rel="icon" href="/favicon.ico" type="image/x-icon">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Lobster&family=Space+Grotesk:wght@500;600&family=Sora:wght@400;500;600&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,500;0,9..144,600;1,9..144,400&family=Sora:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="static/css/style.css">
|
<link rel="stylesheet" href="static/css/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div id="scroll-progress" class="scroll-progress" aria-hidden="true"></div>
|
||||||
<header class="top-bar">
|
<header class="top-bar">
|
||||||
<div class="logo">Jacuzzi</div>
|
<div class="logo">Jacuzzi</div>
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
@@ -33,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>
|
||||||
@@ -90,6 +95,28 @@
|
|||||||
<option value="360">360p</option>
|
<option value="360">360p</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<label for="density-select">Grid Density</label>
|
||||||
|
<select id="density-select">
|
||||||
|
<option value="comfortable">Comfortable</option>
|
||||||
|
<option value="compact">Compact</option>
|
||||||
|
</select>
|
||||||
|
</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">
|
||||||
|
<label for="feed-end-select">Reels: On Video End</label>
|
||||||
|
<select id="feed-end-select">
|
||||||
|
<option value="loop">Loop</option>
|
||||||
|
<option value="scroll">Scroll to Next</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="setting-item setting-toggle">
|
<div class="setting-item setting-toggle">
|
||||||
<div class="setting-label-row">
|
<div class="setting-label-row">
|
||||||
<label for="favorites-toggle">Favorites Bar</label>
|
<label for="favorites-toggle">Favorites Bar</label>
|
||||||
@@ -110,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>
|
||||||
|
|
||||||
@@ -119,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()">×</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">
|
||||||
@@ -154,13 +187,34 @@
|
|||||||
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
|
<button id="error-toast-close" type="button" aria-label="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="update-banner" class="update-banner" role="status" aria-live="polite">
|
||||||
|
<span>A new version is available.</span>
|
||||||
|
<button id="update-banner-btn" type="button">Refresh</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="back-to-top" class="back-to-top" type="button" title="Back to top" aria-label="Back to top">↑</button>
|
||||||
|
|
||||||
|
<div id="command-palette" class="command-palette" aria-hidden="true">
|
||||||
|
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||||
|
<input id="cmdk-input" class="cmdk-input" type="text" placeholder="Type a command or search… (⌘K)" autocomplete="off" spellcheck="false">
|
||||||
|
<div id="cmdk-list" class="cmdk-list" role="listbox"></div>
|
||||||
|
</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>
|
||||||
|
<script src="static/js/version.js"></script>
|
||||||
|
<script src="static/js/enhance.js"></script>
|
||||||
<script src="static/js/main.js"></script>
|
<script src="static/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
338
frontend/js/customPlayer.js
Normal file
338
frontend/js/customPlayer.js
Normal 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);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})();
|
||||||
272
frontend/js/enhance.js
Normal file
272
frontend/js/enhance.js
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
window.App = window.App || {};
|
||||||
|
App.enhance = App.enhance || {};
|
||||||
|
|
||||||
|
// Progressive UI enhancements layered on top of the core app. Everything here
|
||||||
|
// is non-essential polish: cursor-tracking card spotlight, a scroll-progress
|
||||||
|
// bar, a back-to-top button, a ⌘K command palette, and hover video previews.
|
||||||
|
// None of it is required for the app to function, so each piece fails soft.
|
||||||
|
(function() {
|
||||||
|
const fineHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches;
|
||||||
|
const reduceMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
|
// ---- Cursor-tracking spotlight border on grid cards ----------------------
|
||||||
|
// One delegated listener keeps per-card vars (--mx/--my) updated; the brass
|
||||||
|
// border gradient that reads them lives in CSS (.video-card::after).
|
||||||
|
function initSpotlight() {
|
||||||
|
const grid = document.getElementById('video-grid');
|
||||||
|
if (!grid || !fineHover) return;
|
||||||
|
grid.addEventListener('pointermove', (e) => {
|
||||||
|
const card = e.target.closest('.video-card');
|
||||||
|
if (!card) return;
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
card.style.setProperty('--mx', (e.clientX - r.left) + 'px');
|
||||||
|
card.style.setProperty('--my', (e.clientY - r.top) + 'px');
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Scroll progress bar + back-to-top FAB -------------------------------
|
||||||
|
function initScrollAffordances() {
|
||||||
|
const bar = document.getElementById('scroll-progress');
|
||||||
|
const fab = document.getElementById('back-to-top');
|
||||||
|
let ticking = false;
|
||||||
|
const onScroll = () => {
|
||||||
|
if (ticking) return;
|
||||||
|
ticking = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
ticking = false;
|
||||||
|
const doc = document.documentElement;
|
||||||
|
const max = doc.scrollHeight - window.innerHeight;
|
||||||
|
const pct = max > 0 ? (window.scrollY / max) * 100 : 0;
|
||||||
|
if (bar) bar.style.width = pct.toFixed(2) + '%';
|
||||||
|
if (fab) fab.classList.toggle('is-visible', window.scrollY > 700);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
if (fab) {
|
||||||
|
fab.addEventListener('click', () => {
|
||||||
|
window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Hover video preview --------------------------------------------------
|
||||||
|
// After a short dwell over a card, play a muted inline clip in place of the
|
||||||
|
// poster — but only if the video's real formats are already resolved (the
|
||||||
|
// grid resolves them lazily on hover/scroll anyway), so we never block or
|
||||||
|
// hammer the backend just to preview.
|
||||||
|
function initHoverPreview() {
|
||||||
|
const grid = document.getElementById('video-grid');
|
||||||
|
if (!grid || !fineHover) return;
|
||||||
|
let dwellTimer = null;
|
||||||
|
let activeCard = null;
|
||||||
|
|
||||||
|
const clearPreview = () => {
|
||||||
|
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
||||||
|
if (activeCard) {
|
||||||
|
const vid = activeCard.querySelector('.card-preview');
|
||||||
|
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
||||||
|
activeCard.classList.remove('is-previewing');
|
||||||
|
activeCard = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPreview = (card) => {
|
||||||
|
if (!App.videos || typeof App.videos.getVideoForCard !== 'function') return;
|
||||||
|
const v = App.videos.getVideoForCard(card);
|
||||||
|
if (!v) return;
|
||||||
|
const meta = v.meta;
|
||||||
|
const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
|
||||||
|
if (!ready) {
|
||||||
|
// Not resolved yet: kick it off so the *next* hover can preview.
|
||||||
|
if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let url = '';
|
||||||
|
try { url = App.videos.buildStreamUrl(v); } catch (e) { return; }
|
||||||
|
if (!url) return;
|
||||||
|
const img = card.querySelector('img');
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
vid.className = 'card-preview';
|
||||||
|
vid.muted = true;
|
||||||
|
vid.loop = true;
|
||||||
|
vid.playsInline = true;
|
||||||
|
vid.setAttribute('playsinline', '');
|
||||||
|
vid.setAttribute('webkit-playsinline', '');
|
||||||
|
vid.preload = 'auto';
|
||||||
|
if (img) vid.style.height = img.getBoundingClientRect().height + 'px';
|
||||||
|
vid.src = url;
|
||||||
|
vid.addEventListener('error', () => { if (vid.isConnected) vid.remove(); }, { once: true });
|
||||||
|
card.appendChild(vid);
|
||||||
|
card.classList.add('is-previewing');
|
||||||
|
const p = vid.play();
|
||||||
|
if (p && p.catch) p.catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
grid.addEventListener('pointerover', (e) => {
|
||||||
|
const card = e.target.closest('.video-card');
|
||||||
|
if (!card || card === activeCard) return;
|
||||||
|
clearPreview();
|
||||||
|
activeCard = card;
|
||||||
|
dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === card) startPreview(card); }, 600);
|
||||||
|
});
|
||||||
|
grid.addEventListener('pointerout', (e) => {
|
||||||
|
const card = e.target.closest('.video-card');
|
||||||
|
if (!card) return;
|
||||||
|
const to = e.relatedTarget;
|
||||||
|
if (to && card.contains(to)) return; // still inside the same card
|
||||||
|
if (card === activeCard) clearPreview();
|
||||||
|
});
|
||||||
|
window.addEventListener('scroll', clearPreview, { passive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Command palette (⌘K / Ctrl+K) ---------------------------------------
|
||||||
|
function initCommandPalette() {
|
||||||
|
const palette = document.getElementById('command-palette');
|
||||||
|
const input = document.getElementById('cmdk-input');
|
||||||
|
const list = document.getElementById('cmdk-list');
|
||||||
|
if (!palette || !input || !list) return;
|
||||||
|
|
||||||
|
let actions = [];
|
||||||
|
let filtered = [];
|
||||||
|
let activeIndex = 0;
|
||||||
|
|
||||||
|
const fireChange = (el) => el && el.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
const buildActions = () => {
|
||||||
|
const out = [];
|
||||||
|
out.push({ label: 'Search videos', hint: 'Focus the search box', run: () => {
|
||||||
|
const s = document.getElementById('search-input'); if (s) { s.focus(); s.select(); }
|
||||||
|
}});
|
||||||
|
const theme = (localStorage.getItem('theme') || 'dark');
|
||||||
|
out.push({ label: `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`, hint: 'Appearance', run: () => {
|
||||||
|
localStorage.setItem('theme', theme === 'light' ? 'dark' : 'light');
|
||||||
|
if (App.ui && App.ui.applyTheme) App.ui.applyTheme();
|
||||||
|
}});
|
||||||
|
const density = (App.storage && App.storage.getDensity) ? App.storage.getDensity() : 'comfortable';
|
||||||
|
out.push({ label: `Grid density: ${density === 'compact' ? 'comfortable' : 'compact'}`, hint: 'Layout', run: () => {
|
||||||
|
if (!App.storage) return;
|
||||||
|
App.storage.setDensity(density === 'compact' ? 'comfortable' : 'compact');
|
||||||
|
if (App.ui && App.ui.applyDensity) App.ui.applyDensity();
|
||||||
|
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
|
||||||
|
}});
|
||||||
|
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: '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'); } });
|
||||||
|
|
||||||
|
const sourceSelect = document.getElementById('source-select');
|
||||||
|
if (sourceSelect) {
|
||||||
|
Array.from(sourceSelect.options).forEach((opt) => {
|
||||||
|
if (opt.value === sourceSelect.value) return;
|
||||||
|
out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const channelSelect = document.getElementById('channel-select');
|
||||||
|
if (channelSelect) {
|
||||||
|
Array.from(channelSelect.options).forEach((opt) => {
|
||||||
|
if (opt.value === channelSelect.value) return;
|
||||||
|
out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
list.innerHTML = '';
|
||||||
|
filtered.forEach((a, i) => {
|
||||||
|
const li = document.createElement('button');
|
||||||
|
li.type = 'button';
|
||||||
|
li.className = 'cmdk-item' + (i === activeIndex ? ' is-active' : '');
|
||||||
|
li.innerHTML = `<span class="cmdk-label"></span><span class="cmdk-hint"></span>`;
|
||||||
|
li.querySelector('.cmdk-label').textContent = a.label;
|
||||||
|
li.querySelector('.cmdk-hint').textContent = a.hint || '';
|
||||||
|
li.addEventListener('click', () => choose(i));
|
||||||
|
li.addEventListener('pointermove', () => { if (activeIndex !== i) { activeIndex = i; render(); } });
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyFilter = () => {
|
||||||
|
const q = input.value.trim().toLowerCase();
|
||||||
|
filtered = q
|
||||||
|
? actions.filter((a) => (a.label + ' ' + (a.hint || '')).toLowerCase().includes(q))
|
||||||
|
: actions.slice();
|
||||||
|
activeIndex = 0;
|
||||||
|
render();
|
||||||
|
};
|
||||||
|
|
||||||
|
const choose = (i) => {
|
||||||
|
const a = filtered[i];
|
||||||
|
close();
|
||||||
|
if (a && a.run) a.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
actions = buildActions();
|
||||||
|
input.value = '';
|
||||||
|
applyFilter();
|
||||||
|
palette.classList.add('open');
|
||||||
|
palette.setAttribute('aria-hidden', 'false');
|
||||||
|
requestAnimationFrame(() => input.focus());
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
palette.classList.remove('open');
|
||||||
|
palette.setAttribute('aria-hidden', 'true');
|
||||||
|
};
|
||||||
|
App.enhance.openPalette = open;
|
||||||
|
|
||||||
|
input.addEventListener('input', applyFilter);
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); scrollActive(); }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); scrollActive(); }
|
||||||
|
else if (e.key === 'Enter') { e.preventDefault(); choose(activeIndex); }
|
||||||
|
else if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||||
|
});
|
||||||
|
const scrollActive = () => {
|
||||||
|
const el = list.children[activeIndex];
|
||||||
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||||
|
};
|
||||||
|
palette.addEventListener('click', (e) => { if (e.target === palette) close(); });
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (palette.classList.contains('open')) close(); else open();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
initSpotlight();
|
||||||
|
initScrollAffordances();
|
||||||
|
initHoverPreview();
|
||||||
|
initCommandPalette();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -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,10 @@ 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;
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
favorites.splice(existingIndex, 1);
|
favorites.splice(existingIndex, 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -86,50 +253,167 @@ App.favorites = App.favorites || {};
|
|||||||
App.favorites.setAll(favorites);
|
App.favorites.setAll(favorites);
|
||||||
App.favorites.renderBar();
|
App.favorites.renderBar();
|
||||||
App.favorites.syncButtons();
|
App.favorites.syncButtons();
|
||||||
|
// Celebrate an add with a brass pop + ring on every button for this key.
|
||||||
|
if (becameFavorite) {
|
||||||
|
document.querySelectorAll(`.favorite-btn[data-fav-key="${(window.CSS && CSS.escape) ? CSS.escape(key) : key}"]`).forEach((btn) => {
|
||||||
|
btn.classList.remove('just-favorited');
|
||||||
|
void btn.offsetWidth; // restart the animation
|
||||||
|
btn.classList.add('just-favorited');
|
||||||
|
btn.addEventListener('animationend', () => btn.classList.remove('just-favorited'), { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
const uploaderText = item.uploader || '';
|
const uploaderText = item.uploader || '';
|
||||||
|
const durationText = (!item.isLive && App.videos && typeof App.videos.formatDuration === 'function')
|
||||||
|
? App.videos.formatDuration(item.duration)
|
||||||
|
: '';
|
||||||
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>
|
||||||
<img src="${item.thumb}" alt="${item.title}" loading="lazy" decoding="async">
|
<div class="video-thumb">
|
||||||
<div class="video-loading" aria-hidden="true">
|
<img alt="${item.title}" loading="lazy" decoding="async">
|
||||||
<div class="video-loading-spinner"></div>
|
<div class="video-loading" aria-hidden="true">
|
||||||
|
<div class="video-loading-spinner"></div>
|
||||||
|
</div>
|
||||||
|
${uploaderText ? `<button class="video-uploader uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button>` : ''}
|
||||||
|
${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>
|
||||||
${uploaderText ? `<p><button class="uploader-link" type="button" data-uploader="${uploaderText}">${uploaderText}</button></p>` : ''}
|
|
||||||
</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) {
|
||||||
@@ -151,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');
|
||||||
@@ -170,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';
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
110
frontend/js/favoritesView.js
Normal file
110
frontend/js/favoritesView.js
Normal 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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -32,6 +32,39 @@ App.feed = App.feed || {};
|
|||||||
let scrollBound = false;
|
let scrollBound = false;
|
||||||
let scrollRaf = null;
|
let scrollRaf = null;
|
||||||
|
|
||||||
|
// While true, scroll events are ignored. A viewport change (e.g. an
|
||||||
|
// orientation switch) makes the scroll-snap container re-snap and fire
|
||||||
|
// scroll events with positions that no longer map to the active slide;
|
||||||
|
// onResize sets this for the brief realign window so those events don't
|
||||||
|
// flip the active video -- rotating the device must never change which
|
||||||
|
// slide is playing. Normal swipes (no resize in flight) are unaffected.
|
||||||
|
let suppressScroll = false;
|
||||||
|
let resizeSettleRaf = null;
|
||||||
|
|
||||||
|
// HUD auto-hide: the reels HUD fades out after this much inactivity and
|
||||||
|
// reappears on any pointer movement / tap / scroll. Buttons keep their
|
||||||
|
// pointer-events while hidden, so they stay clickable even when invisible.
|
||||||
|
const HUD_IDLE_MS = 1000;
|
||||||
|
let hudIdleTimer = null;
|
||||||
|
let hudActivityBound = false;
|
||||||
|
|
||||||
|
const scheduleHudHide = function() {
|
||||||
|
if (hudIdleTimer) clearTimeout(hudIdleTimer);
|
||||||
|
hudIdleTimer = setTimeout(() => {
|
||||||
|
hudIdleTimer = null;
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const wakeHud = function() {
|
||||||
|
document.body.classList.remove('feed-hud-idle');
|
||||||
|
if (state.feedOpen) scheduleHudHide();
|
||||||
|
};
|
||||||
|
|
||||||
const getScroller = () => document.getElementById('feed-scroll');
|
const getScroller = () => document.getElementById('feed-scroll');
|
||||||
const getSentinel = () => document.getElementById('feed-sentinel');
|
const getSentinel = () => document.getElementById('feed-sentinel');
|
||||||
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
const getTopSpacer = () => document.getElementById('feed-top-spacer');
|
||||||
@@ -47,9 +80,67 @@ App.feed = App.feed || {};
|
|||||||
return Math.min(total - 1, Math.max(0, index));
|
return Math.min(total - 1, Math.max(0, index));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// True when the user wants a finished clip to replay; false when it should
|
||||||
|
// auto-advance to the next video. Defaults to looping (see storage).
|
||||||
|
const shouldLoop = function() {
|
||||||
|
return App.storage.getFeedEndBehavior() !== 'scroll';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Smoothly scrolls to the slide after `fromIndex`; the scroll-snap container
|
||||||
|
// fires onScroll, which promotes the new slide to active. No-ops at the end
|
||||||
|
// of the list so the final clip simply stops on its last frame.
|
||||||
|
const advanceToNext = function(fromIndex) {
|
||||||
|
const next = clampIndex(fromIndex + 1);
|
||||||
|
if (next < 0 || next === fromIndex) return;
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remembers playback position per video id so scrolling away and back
|
||||||
|
// resumes where the user left off. Slides kept in the window are merely
|
||||||
|
// paused (instant resume); slides whose <video> is torn down to free
|
||||||
|
// resources still have their position restored on reload via applyResume.
|
||||||
|
const KEEP_BEHIND = 2; // slides behind the active one kept loaded & paused
|
||||||
|
const resumeTimes = new Map();
|
||||||
|
|
||||||
|
const slideVideoId = (slide) => (slide && slide._videoData ? slide._videoData.id : null);
|
||||||
|
|
||||||
|
const rememberTime = function(slide, video) {
|
||||||
|
if (!slide || !video || slide.classList.contains('is-live')) return;
|
||||||
|
const id = slideVideoId(slide);
|
||||||
|
if (id == null) return;
|
||||||
|
const t = video.currentTime;
|
||||||
|
if (isFinite(t) && t > 0.5) resumeTimes.set(id, t);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyResume = function(video, videoId, isLive) {
|
||||||
|
if (!video || isLive || videoId == null) return;
|
||||||
|
const t = resumeTimes.get(videoId);
|
||||||
|
if (t == null || t <= 0) return;
|
||||||
|
const seek = () => {
|
||||||
|
let target = t;
|
||||||
|
if (isFinite(video.duration) && video.duration > 0) {
|
||||||
|
target = Math.min(t, video.duration - 0.25);
|
||||||
|
}
|
||||||
|
try { video.currentTime = Math.max(0, target); } catch (err) { /* ignore */ }
|
||||||
|
};
|
||||||
|
if (video.readyState >= 1) seek();
|
||||||
|
else video.addEventListener('loadedmetadata', seek, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pauses a slide but keeps its <video> loaded so returning to it resumes
|
||||||
|
// instantly from the exact frame it was paused on.
|
||||||
|
const pauseSlide = function(slide) {
|
||||||
|
const video = slide.querySelector('.feed-video');
|
||||||
|
slide.classList.remove('is-active');
|
||||||
|
if (video && !video.paused) video.pause();
|
||||||
|
rememberTime(slide, video);
|
||||||
|
};
|
||||||
|
|
||||||
const destroySlidePlayback = function(slide) {
|
const destroySlidePlayback = function(slide) {
|
||||||
const video = slide.querySelector('.feed-video');
|
const video = slide.querySelector('.feed-video');
|
||||||
slide.classList.remove('is-active');
|
slide.classList.remove('is-active');
|
||||||
|
rememberTime(slide, video);
|
||||||
const fill = slide.querySelector('.feed-timeline-fill');
|
const fill = slide.querySelector('.feed-timeline-fill');
|
||||||
if (fill) fill.style.width = '0%';
|
if (fill) fill.style.width = '0%';
|
||||||
const handle = slide.querySelector('.feed-timeline-handle');
|
const handle = slide.querySelector('.feed-timeline-handle');
|
||||||
@@ -59,12 +150,22 @@ App.feed = App.feed || {};
|
|||||||
video._hlsPlayer.destroy();
|
video._hlsPlayer.destroy();
|
||||||
video._hlsPlayer = null;
|
video._hlsPlayer = null;
|
||||||
}
|
}
|
||||||
|
// Clearing the src below makes the element fire a spurious `error` event;
|
||||||
|
// flag the teardown so the failure handler ignores it (see markSlideFailed).
|
||||||
|
video._tearingDown = true;
|
||||||
video.pause();
|
video.pause();
|
||||||
video.removeAttribute('src');
|
video.removeAttribute('src');
|
||||||
video.load();
|
video.load();
|
||||||
slide.classList.remove('is-loaded');
|
slide.classList.remove('is-loaded');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Single-line slide title that scrolls when it overflows. Only the active
|
||||||
|
// slide's title is measured, so like the player it always scrolls.
|
||||||
|
const measureFeedTitle = function(slide) {
|
||||||
|
if (!slide) return;
|
||||||
|
App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
|
||||||
|
};
|
||||||
|
|
||||||
const setTimelinePosition = function(slide, ratio) {
|
const setTimelinePosition = function(slide, ratio) {
|
||||||
const fill = slide.querySelector('.feed-timeline-fill');
|
const fill = slide.querySelector('.feed-timeline-fill');
|
||||||
const handle = slide.querySelector('.feed-timeline-handle');
|
const handle = slide.querySelector('.feed-timeline-handle');
|
||||||
@@ -121,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;
|
||||||
@@ -132,18 +321,41 @@ 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) return;
|
? App.videos.resolveSourceForFormat(videoData, slide._formatOverride)
|
||||||
const refererParam = resolved.referer ? `&referer=${encodeURIComponent(resolved.referer)}` : '';
|
: App.videos.resolveStreamSource(videoData);
|
||||||
const userAgentParam = resolved.userAgent ? `&User-Agent=${encodeURIComponent(resolved.userAgent)}` : '';
|
if (!resolved || !resolved.url) {
|
||||||
const liveParam = resolved.isLive ? '&live=1' : '';
|
// No playable source -- treat exactly like a load failure so the
|
||||||
const streamUrl = `/api/stream?url=${encodeURIComponent(resolved.url)}${refererParam}${userAgentParam}${liveParam}`;
|
// clip is dropped from the queue and the next one takes its place.
|
||||||
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
|
markSlideFailed(slide);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// What's actually on screen, so the quality menu can tick it.
|
||||||
|
slide._activeUrl = resolved.url;
|
||||||
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||||
|
const isHls = App.videos.classifySource(resolved).isHls;
|
||||||
|
|
||||||
video.muted = state.feedMuted;
|
video.muted = state.feedMuted;
|
||||||
video.preload = 'auto';
|
video.preload = 'auto';
|
||||||
|
video._tearingDown = false;
|
||||||
|
applyResume(video, videoData && videoData.id, resolved.isLive);
|
||||||
|
|
||||||
const startPlay = () => {
|
const startPlay = () => {
|
||||||
if (!autoplay) return;
|
if (!autoplay) return;
|
||||||
@@ -160,6 +372,8 @@ App.feed = App.feed || {};
|
|||||||
if (data && data.fatal && video._hlsPlayer === hls) {
|
if (data && data.fatal && video._hlsPlayer === hls) {
|
||||||
hls.destroy();
|
hls.destroy();
|
||||||
video._hlsPlayer = null;
|
video._hlsPlayer = null;
|
||||||
|
// A fatal HLS error means the stream won't play: drop it.
|
||||||
|
markSlideFailed(slide);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
startPlay();
|
startPlay();
|
||||||
@@ -208,12 +422,20 @@ App.feed = App.feed || {};
|
|||||||
slide._index = index;
|
slide._index = index;
|
||||||
const uploaderText = v.uploader || '';
|
const uploaderText = v.uploader || '';
|
||||||
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;
|
||||||
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 loop 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}" 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">${v.title || ''}</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>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="feed-timeline" role="slider" aria-label="Seek">
|
<div class="feed-timeline" role="slider" aria-label="Seek">
|
||||||
@@ -224,8 +446,35 @@ 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);
|
||||||
bindTimeline(slide, slide.querySelector('.feed-video'));
|
const slideVideo = slide.querySelector('.feed-video');
|
||||||
|
bindTimeline(slide, slideVideo);
|
||||||
|
bindSharedControls(slide, slideVideo, v);
|
||||||
|
|
||||||
|
// A media error (bad/expired source, network failure, unsupported codec)
|
||||||
|
// means this clip can't play -- drop it from the queue. Errors fired by
|
||||||
|
// our own teardown (src cleared) carry the _tearingDown flag and are
|
||||||
|
// ignored inside markSlideFailed.
|
||||||
|
slideVideo.addEventListener('error', () => markSlideFailed(slide));
|
||||||
|
|
||||||
|
// On video end, either loop (handled by the `loop` flag, so `ended`
|
||||||
|
// never fires) or auto-scroll to the next clip. We only advance for the
|
||||||
|
// active slide so a preloaded neighbour ending early can't hijack focus.
|
||||||
|
slideVideo.loop = shouldLoop();
|
||||||
|
slideVideo.addEventListener('ended', () => {
|
||||||
|
if (shouldLoop()) return;
|
||||||
|
if (!slide.classList.contains('is-active')) return;
|
||||||
|
advanceToNext(slide._index);
|
||||||
|
});
|
||||||
|
|
||||||
|
const favBtn = slide.querySelector('.feed-fav-btn');
|
||||||
|
if (favBtn && App.favorites) {
|
||||||
|
App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0);
|
||||||
|
favBtn.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
App.favorites.toggle(v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Insert before the rendered slide with the next-highest index so DOM
|
// Insert before the rendered slide with the next-highest index so DOM
|
||||||
// order always matches index order; fall back to the sentinel.
|
// order always matches index order; fall back to the sentinel.
|
||||||
@@ -242,14 +491,102 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-keys every rendered slide after `removedIndex` was spliced out of
|
||||||
|
// state.loadedVideos: indices past the hole shift down by one so
|
||||||
|
// slidesByIndex (and each slide's _index) stays aligned with the queue.
|
||||||
|
const reindexAfterRemoval = function(removedIndex) {
|
||||||
|
const entries = [];
|
||||||
|
slidesByIndex.forEach((slide, i) => entries.push([i, slide]));
|
||||||
|
slidesByIndex.clear();
|
||||||
|
entries.forEach(([i, slide]) => {
|
||||||
|
const ni = i > removedIndex ? i - 1 : i;
|
||||||
|
slide._index = ni;
|
||||||
|
slide.dataset.index = String(ni);
|
||||||
|
slidesByIndex.set(ni, slide);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drops a video that failed to load/resolve from the queue and pulls the
|
||||||
|
// next clip into its place. A failed *preload* neighbour leaves the active
|
||||||
|
// video playing untouched; a failed *active* clip is replaced in-place by
|
||||||
|
// the next one (the broken frame is removed and the next clip slides into
|
||||||
|
// the same scroll position, so playback advances without a visible jump).
|
||||||
|
const removeVideoFromQueue = function(videoId) {
|
||||||
|
const videos = state.loadedVideos || [];
|
||||||
|
const r = videos.findIndex((v) => String(v.id) === String(videoId));
|
||||||
|
if (r < 0) return;
|
||||||
|
const prevActive = state.feedActiveIndex;
|
||||||
|
|
||||||
|
// Drop the failed clip's feed slide element from the DOM, then its JSON
|
||||||
|
// from the queue, then re-key the remaining rendered slides.
|
||||||
|
removeSlide(r);
|
||||||
|
videos.splice(r, 1);
|
||||||
|
reindexAfterRemoval(r);
|
||||||
|
|
||||||
|
// Remove the failed clip's grid card element from the DOM too (the grid
|
||||||
|
// shares the queue) and re-pack the remaining cards.
|
||||||
|
if (App.virtualGrid && typeof App.virtualGrid.removeVideo === 'function') {
|
||||||
|
App.virtualGrid.removeVideo(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videos.length === 0) {
|
||||||
|
App.feed.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The active slot only moves when the removed clip was the active one
|
||||||
|
// (r === prevActive) or, defensively, sat before it.
|
||||||
|
let newActive = prevActive;
|
||||||
|
if (r < prevActive) newActive -= 1;
|
||||||
|
newActive = clampIndex(newActive);
|
||||||
|
|
||||||
|
state.feedActiveIndex = -1; // force setActive to re-promote the slot
|
||||||
|
setActive(newActive);
|
||||||
|
|
||||||
|
if (r <= prevActive) {
|
||||||
|
// Active clip failed: re-anchor scroll onto the clip that slid into
|
||||||
|
// its slot so the snap container stays pinned to the new active.
|
||||||
|
const scroller = getScroller();
|
||||||
|
if (scroller) scroller.scrollTop = newActive * slideHeight();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flags a slide whose video failed and schedules its removal from the queue.
|
||||||
|
// Deferred to a macrotask so we never mutate slidesByIndex while setActive /
|
||||||
|
// syncWindow is mid-iteration over it. Teardown-induced errors (src cleared)
|
||||||
|
// are ignored via the video's _tearingDown flag, and we only act while the
|
||||||
|
// feed is open so late errors after close are harmless.
|
||||||
|
const markSlideFailed = function(slide) {
|
||||||
|
if (!slide || slide._failed || !state.feedOpen) return;
|
||||||
|
const video = slide.querySelector('.feed-video');
|
||||||
|
if (video && video._tearingDown) return;
|
||||||
|
const id = slideVideoId(slide);
|
||||||
|
if (id == null) return;
|
||||||
|
slide._failed = true;
|
||||||
|
setTimeout(() => removeVideoFromQueue(id), 0);
|
||||||
|
};
|
||||||
|
|
||||||
// Brings the rendered window in line with the active index: drops slides
|
// Brings the rendered window in line with the active index: drops slides
|
||||||
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
// that fell outside [active - HISTORY_COUNT, active + RENDER_AHEAD], builds
|
||||||
// any missing ones inside it, and sizes the top spacer to stand in for the
|
// any missing ones inside it, and sizes the top spacer to stand in for the
|
||||||
@@ -278,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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -289,6 +628,8 @@ App.feed = App.feed || {};
|
|||||||
const clamped = clampIndex(index);
|
const clamped = clampIndex(index);
|
||||||
if (clamped < 0) return;
|
if (clamped < 0) return;
|
||||||
state.feedActiveIndex = clamped;
|
state.feedActiveIndex = clamped;
|
||||||
|
const activeVideo = (state.loadedVideos || [])[clamped];
|
||||||
|
state.feedActiveVideoId = activeVideo ? activeVideo.id : null;
|
||||||
|
|
||||||
syncWindow(clamped);
|
syncWindow(clamped);
|
||||||
|
|
||||||
@@ -297,12 +638,19 @@ App.feed = App.feed || {};
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activeSlide = slidesByIndex.get(clamped);
|
const activeSlide = slidesByIndex.get(clamped);
|
||||||
if (activeSlide) loadSlideSource(activeSlide, activeSlide._videoData, true);
|
if (activeSlide) {
|
||||||
|
loadSlideSource(activeSlide, activeSlide._videoData, true);
|
||||||
|
requestAnimationFrame(() => measureFeedTitle(activeSlide));
|
||||||
|
}
|
||||||
|
|
||||||
slidesByIndex.forEach((slide, i) => {
|
slidesByIndex.forEach((slide, i) => {
|
||||||
if (i === clamped) return;
|
if (i === clamped) return;
|
||||||
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
if (i > clamped && i <= clamped + PRELOAD_COUNT) {
|
||||||
loadSlideSource(slide, slide._videoData, false);
|
loadSlideSource(slide, slide._videoData, false);
|
||||||
|
} else if (i >= clamped - KEEP_BEHIND && i < clamped) {
|
||||||
|
// Recently-watched slides stay loaded but paused so scrolling
|
||||||
|
// back resumes seamlessly from where it was paused.
|
||||||
|
if (slide.classList.contains('is-loaded')) pauseSlide(slide);
|
||||||
} else if (slide.classList.contains('is-loaded')) {
|
} else if (slide.classList.contains('is-loaded')) {
|
||||||
destroySlidePlayback(slide);
|
destroySlidePlayback(slide);
|
||||||
}
|
}
|
||||||
@@ -312,11 +660,15 @@ App.feed = App.feed || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onScroll = function() {
|
const onScroll = function() {
|
||||||
|
wakeHud();
|
||||||
if (scrollRaf) return;
|
if (scrollRaf) return;
|
||||||
scrollRaf = requestAnimationFrame(() => {
|
scrollRaf = requestAnimationFrame(() => {
|
||||||
scrollRaf = null;
|
scrollRaf = null;
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
|
// Ignore scroll events fired by a resize/orientation re-snap; the
|
||||||
|
// active video is realigned by onResize instead (see suppressScroll).
|
||||||
|
if (suppressScroll) return;
|
||||||
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
const index = clampIndex(Math.round(scroller.scrollTop / slideHeight()));
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
if (index !== state.feedActiveIndex) {
|
if (index !== state.feedActiveIndex) {
|
||||||
@@ -325,20 +677,65 @@ App.feed = App.feed || {};
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResize = function() {
|
// Re-anchors the scroll position on the currently active video after the
|
||||||
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
// viewport changes. The active slide is resolved by id (not by a possibly
|
||||||
|
// stale scroll position) so an orientation change always keeps the same
|
||||||
|
// video playing/focused rather than snapping to a neighbour.
|
||||||
|
const realignToActive = function() {
|
||||||
|
const total = (state.loadedVideos || []).length;
|
||||||
|
if (total === 0) return;
|
||||||
|
let index = state.feedActiveIndex;
|
||||||
|
if (state.feedActiveVideoId != null) {
|
||||||
|
const found = (state.loadedVideos || [])
|
||||||
|
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
|
||||||
|
if (found >= 0) index = found;
|
||||||
|
}
|
||||||
|
index = clampIndex(index);
|
||||||
|
if (index < 0) return;
|
||||||
|
state.feedActiveIndex = index;
|
||||||
const h = slideHeight();
|
const h = slideHeight();
|
||||||
const start = Math.max(0, state.feedActiveIndex - HISTORY_COUNT);
|
const start = Math.max(0, index - HISTORY_COUNT);
|
||||||
const spacer = getTopSpacer();
|
const spacer = getTopSpacer();
|
||||||
if (spacer) spacer.style.height = `${start * h}px`;
|
if (spacer) spacer.style.height = `${start * h}px`;
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
if (scroller) scroller.scrollTop = state.feedActiveIndex * h;
|
if (scroller) scroller.scrollTop = index * h;
|
||||||
|
const activeSlide = slidesByIndex.get(index);
|
||||||
|
if (activeSlide) measureFeedTitle(activeSlide);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResize = function() {
|
||||||
|
if (!state.feedOpen || state.feedActiveIndex < 0) return;
|
||||||
|
// Suppress scroll handling while we realign so the container's re-snap
|
||||||
|
// doesn't flip the active video, then re-enable it once layout settles.
|
||||||
|
suppressScroll = true;
|
||||||
|
realignToActive();
|
||||||
|
// Orientation changes can settle over more than one frame (the visual
|
||||||
|
// viewport and the scroll-snap re-anchor in stages); realign again once
|
||||||
|
// layout has settled, then stop suppressing real swipes.
|
||||||
|
if (resizeSettleRaf) cancelAnimationFrame(resizeSettleRaf);
|
||||||
|
resizeSettleRaf = requestAnimationFrame(() => {
|
||||||
|
realignToActive();
|
||||||
|
resizeSettleRaf = requestAnimationFrame(() => {
|
||||||
|
resizeSettleRaf = null;
|
||||||
|
suppressScroll = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
App.feed.isOpen = function() {
|
App.feed.isOpen = function() {
|
||||||
return !!state.feedOpen;
|
return !!state.feedOpen;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Re-applies the on-video-end preference to every rendered slide so toggling
|
||||||
|
// the setting takes effect immediately, without needing to reopen the feed.
|
||||||
|
App.feed.applyEndBehavior = function() {
|
||||||
|
const loop = shouldLoop();
|
||||||
|
slidesByIndex.forEach((slide) => {
|
||||||
|
const video = slide.querySelector('.feed-video');
|
||||||
|
if (video) video.loop = loop;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Called whenever new video JSON is appended (e.g. after a prefetch). Lets
|
// Called whenever new video JSON is appended (e.g. after a prefetch). Lets
|
||||||
// the open feed pick up newly buffered slides and extend its window if the
|
// the open feed pick up newly buffered slides and extend its window if the
|
||||||
// active slide is near the end.
|
// active slide is near the end.
|
||||||
@@ -349,11 +746,18 @@ 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();
|
||||||
|
resumeTimes.clear();
|
||||||
state.feedActiveIndex = -1;
|
state.feedActiveIndex = -1;
|
||||||
|
state.feedActiveVideoId = null;
|
||||||
|
suppressScroll = false;
|
||||||
|
if (resizeSettleRaf) {
|
||||||
|
cancelAnimationFrame(resizeSettleRaf);
|
||||||
|
resizeSettleRaf = null;
|
||||||
|
}
|
||||||
const spacer = getTopSpacer();
|
const spacer = getTopSpacer();
|
||||||
if (spacer) spacer.style.height = '0px';
|
if (spacer) spacer.style.height = '0px';
|
||||||
const scroller = getScroller();
|
const scroller = getScroller();
|
||||||
@@ -367,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');
|
||||||
@@ -381,6 +790,13 @@ App.feed = App.feed || {};
|
|||||||
scrollBound = true;
|
scrollBound = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hudActivityBound) {
|
||||||
|
container.addEventListener('mousemove', wakeHud, { passive: true });
|
||||||
|
container.addEventListener('pointerdown', wakeHud, { passive: true });
|
||||||
|
container.addEventListener('touchstart', wakeHud, { passive: true });
|
||||||
|
hudActivityBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Start from whichever grid video the user was looking at.
|
// Start from whichever grid video the user was looking at.
|
||||||
let startIndex = 0;
|
let startIndex = 0;
|
||||||
if (startVideoId != null) {
|
if (startVideoId != null) {
|
||||||
@@ -396,12 +812,18 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
App.feed.updateToggleButton();
|
App.feed.updateToggleButton();
|
||||||
App.feed.updateMuteButton();
|
App.feed.updateMuteButton();
|
||||||
|
wakeHud();
|
||||||
};
|
};
|
||||||
|
|
||||||
App.feed.close = function() {
|
App.feed.close = function() {
|
||||||
const container = document.getElementById('feed-view');
|
const container = document.getElementById('feed-view');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
state.feedOpen = false;
|
state.feedOpen = false;
|
||||||
|
if (hudIdleTimer) {
|
||||||
|
clearTimeout(hudIdleTimer);
|
||||||
|
hudIdleTimer = null;
|
||||||
|
}
|
||||||
|
document.body.classList.remove('feed-hud-idle');
|
||||||
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
slidesByIndex.forEach((slide) => destroySlidePlayback(slide));
|
||||||
container.classList.remove('open');
|
container.classList.remove('open');
|
||||||
container.setAttribute('aria-hidden', 'true');
|
container.setAttribute('aria-hidden', 'true');
|
||||||
@@ -450,5 +872,8 @@ App.feed = App.feed || {};
|
|||||||
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
|
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
|
||||||
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
|
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
|
||||||
icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
|
icon.alt = state.feedMuted ? 'Unmute' : 'Mute';
|
||||||
|
// Pulse a brass ring while muted to hint "tap to hear sound".
|
||||||
|
const btn = document.getElementById('feed-mute-btn');
|
||||||
|
if (btn) btn.classList.toggle('is-muted', !!state.feedMuted);
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
79
frontend/js/hottubBackup.js
Normal file
79
frontend/js/hottubBackup.js
Normal 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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -6,6 +6,11 @@ window.App = window.App || {};
|
|||||||
await App.storage.ensureDefaults();
|
await App.storage.ensureDefaults();
|
||||||
App.ui.applyTheme();
|
App.ui.applyTheme();
|
||||||
App.ui.applyPreferredQuality();
|
App.ui.applyPreferredQuality();
|
||||||
|
App.ui.applyFeedEndBehavior();
|
||||||
|
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();
|
||||||
@@ -15,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 });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +38,17 @@ window.App = window.App || {};
|
|||||||
|
|
||||||
await App.videos.loadVideos();
|
await App.videos.loadVideos();
|
||||||
App.favorites.syncButtons();
|
App.favorites.syncButtons();
|
||||||
|
|
||||||
|
// The UI above is rendered entirely from the last known status cached in
|
||||||
|
// localStorage, so startup never blocks on (or breaks because of) a slow
|
||||||
|
// or failing status endpoint. Now fetch fresh status in the background and
|
||||||
|
// reconcile the UI with whatever comes back.
|
||||||
|
App.storage.refreshServerStatusInBackground();
|
||||||
|
|
||||||
|
// Watch for frontend deploys and seamlessly reload/hot-swap changed assets.
|
||||||
|
if (App.version && App.version.start) {
|
||||||
|
App.version.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
initApp();
|
initApp();
|
||||||
|
|||||||
51
frontend/js/marquee.js
Normal file
51
frontend/js/marquee.js
Normal 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
BIN
frontend/js/sqlite.js
Normal file
Binary file not shown.
@@ -10,14 +10,11 @@ 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,
|
||||||
feedActiveIndex: -1,
|
feedActiveIndex: -1,
|
||||||
|
feedActiveVideoId: null,
|
||||||
groupCursors: null
|
groupCursors: null
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,7 +22,9 @@ App.state = {
|
|||||||
App.constants = {
|
App.constants = {
|
||||||
FAVORITES_KEY: 'favorites',
|
FAVORITES_KEY: 'favorites',
|
||||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||||
PREFERRED_QUALITY_KEY: 'preferredQuality'
|
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||||
|
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||||
|
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ App.storage = App.storage || {};
|
|||||||
App.session = App.session || {};
|
App.session = App.session || {};
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY } = App.constants;
|
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY, PREFERRED_QUALITY_KEY, FEED_END_BEHAVIOR_KEY } = App.constants;
|
||||||
|
|
||||||
// Basic localStorage helpers.
|
// Basic localStorage helpers.
|
||||||
App.storage.getConfig = function() {
|
App.storage.getConfig = function() {
|
||||||
@@ -38,6 +38,49 @@ App.session = App.session || {};
|
|||||||
localStorage.setItem(PREFERRED_QUALITY_KEY, nextQuality);
|
localStorage.setItem(PREFERRED_QUALITY_KEY, nextQuality);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reels/TikTok mode behavior when a video reaches its end: 'loop' replays
|
||||||
|
// the same clip; 'scroll' advances to the next video. Defaults to 'loop'.
|
||||||
|
App.storage.getFeedEndBehavior = function() {
|
||||||
|
return localStorage.getItem(FEED_END_BEHAVIOR_KEY) === 'scroll' ? 'scroll' : 'loop';
|
||||||
|
};
|
||||||
|
|
||||||
|
App.storage.setFeedEndBehavior = function(nextBehavior) {
|
||||||
|
localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Grid density: 'comfortable' (default) or 'compact' (more, smaller columns).
|
||||||
|
App.storage.getDensity = function() {
|
||||||
|
return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable';
|
||||||
|
};
|
||||||
|
|
||||||
|
App.storage.setDensity = function(nextDensity) {
|
||||||
|
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 [];
|
||||||
@@ -164,7 +207,11 @@ App.session = App.session || {};
|
|||||||
return selected;
|
return selected;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ensures defaults exist and refreshes server status.
|
// Ensures defaults exist and establishes a session from cached status.
|
||||||
|
// Intentionally does NOT touch the network: the last known status of every
|
||||||
|
// server is persisted in localStorage, so the UI can render instantly from
|
||||||
|
// it. Fresh status is fetched separately (and non-blockingly) via
|
||||||
|
// refreshServerStatusInBackground().
|
||||||
App.storage.ensureDefaults = async function() {
|
App.storage.ensureDefaults = async function() {
|
||||||
if (!localStorage.getItem('config')) {
|
if (!localStorage.getItem('config')) {
|
||||||
localStorage.setItem('config', JSON.stringify({
|
localStorage.setItem('config', JSON.stringify({
|
||||||
@@ -187,64 +234,154 @@ App.session = App.session || {};
|
|||||||
if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) {
|
if (!localStorage.getItem(FAVORITES_VISIBILITY_KEY)) {
|
||||||
localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true');
|
localStorage.setItem(FAVORITES_VISIBILITY_KEY, 'true');
|
||||||
}
|
}
|
||||||
await App.storage.initializeServerStatus();
|
if (!localStorage.getItem(FEED_END_BEHAVIOR_KEY)) {
|
||||||
|
localStorage.setItem(FEED_END_BEHAVIOR_KEY, 'loop');
|
||||||
|
}
|
||||||
|
App.storage.ensureSessionFromCache();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetches server status and keeps the session pointing to a valid channel/options.
|
// A stable fingerprint of which server/channel a session targets, used to
|
||||||
|
// decide whether a status refresh actually changed what's being shown (and
|
||||||
|
// thus whether videos need reloading).
|
||||||
|
function sessionSignature(session) {
|
||||||
|
if (!session) return '';
|
||||||
|
return `${session.server}::${session.channel ? session.channel.id : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a session pointing at a valid channel/options using ONLY the status
|
||||||
|
// data already cached in `config` (no network). Returns the session object,
|
||||||
|
// or null if no server in the config currently exposes any channels.
|
||||||
|
App.session.buildSessionFromCache = function(config) {
|
||||||
|
if (!config || !Array.isArray(config.servers) || config.servers.length === 0) return null;
|
||||||
|
const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]);
|
||||||
|
const existingSession = App.storage.getSession();
|
||||||
|
const selectedServerKey = existingSession && serverKeys.includes(existingSession.server)
|
||||||
|
? existingSession.server
|
||||||
|
: serverKeys[0];
|
||||||
|
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey);
|
||||||
|
const serverData = serverEntry ? serverEntry[selectedServerKey] : null;
|
||||||
|
if (!serverData || !Array.isArray(serverData.channels) || serverData.channels.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const prefs = App.storage.getPreferences();
|
||||||
|
const serverPrefs = prefs[selectedServerKey] || {};
|
||||||
|
const channel = App.session.resolveChannelById(serverData, serverPrefs.channelId) || serverData.channels[0];
|
||||||
|
const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null;
|
||||||
|
const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel);
|
||||||
|
return {
|
||||||
|
server: selectedServerKey,
|
||||||
|
channel: channel,
|
||||||
|
options: options,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensures the stored session points at a channel that still exists in the
|
||||||
|
// cached status, rebuilding it from cache if necessary. Never clears a valid
|
||||||
|
// selection. Returns true if a usable session exists afterwards.
|
||||||
|
App.storage.ensureSessionFromCache = function() {
|
||||||
|
const config = App.storage.getConfig();
|
||||||
|
const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]);
|
||||||
|
const existingSession = App.storage.getSession();
|
||||||
|
|
||||||
|
// Leave a still-valid session untouched so we don't disturb the user's
|
||||||
|
// current server/channel selection on refresh.
|
||||||
|
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {
|
||||||
|
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === existingSession.server);
|
||||||
|
const serverData = serverEntry ? serverEntry[existingSession.server] : null;
|
||||||
|
if (serverData && App.session.resolveChannelById(serverData, existingSession.channel.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = App.session.buildSessionFromCache(config);
|
||||||
|
if (sessionData) {
|
||||||
|
App.storage.setSession(sessionData);
|
||||||
|
App.session.savePreference(sessionData);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetches fresh server status and merges it into the cached config. Crucially,
|
||||||
|
// a failed status request preserves the server's LAST KNOWN status (channels,
|
||||||
|
// groups, etc.) instead of wiping it -- so a flaky/down status endpoint can no
|
||||||
|
// longer brick the app. Returns true if the active session's target changed
|
||||||
|
// (e.g. channels appeared for the first time), signalling a video reload.
|
||||||
App.storage.initializeServerStatus = async function() {
|
App.storage.initializeServerStatus = async function() {
|
||||||
const config = JSON.parse(localStorage.getItem('config'));
|
const config = JSON.parse(localStorage.getItem('config'));
|
||||||
if (!config || !config.servers) return;
|
if (!config || !config.servers) return false;
|
||||||
|
|
||||||
|
const fetchDirectStatus = async (server) => {
|
||||||
|
const directUrl = `${server.replace(/\/$/, '')}/api/status`;
|
||||||
|
const response = await fetch(directUrl);
|
||||||
|
if (!response.ok) throw new Error(`Direct status request failed: ${response.status}`);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchProxiedStatus = async (server) => {
|
||||||
|
const response = await fetch(`/api/status`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
server: server
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Proxied status request failed: ${response.status}`);
|
||||||
|
return await response.json();
|
||||||
|
};
|
||||||
|
|
||||||
const statusPromises = config.servers.map(async (serverObj) => {
|
const statusPromises = config.servers.map(async (serverObj) => {
|
||||||
const server = Object.keys(serverObj)[0];
|
const server = Object.keys(serverObj)[0];
|
||||||
|
const prior = serverObj[server];
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/status`, {
|
// Try a direct request first, then fall back to the server-side proxy.
|
||||||
method: "POST",
|
try {
|
||||||
body: JSON.stringify({
|
serverObj[server] = await fetchDirectStatus(server);
|
||||||
server: server
|
} catch (directErr) {
|
||||||
}),
|
serverObj[server] = await fetchProxiedStatus(server);
|
||||||
headers: {
|
}
|
||||||
"Content-Type": "application/json"
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const status = await response.json();
|
|
||||||
serverObj[server] = status;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
serverObj[server] = {
|
// The request failed. Keep the last known good status so the user
|
||||||
online: false,
|
// doesn't lose their channels when the status endpoint is down;
|
||||||
channels: []
|
// just flag it offline. Only fall back to an empty stub when we've
|
||||||
};
|
// never successfully fetched this server.
|
||||||
|
if (prior && Array.isArray(prior.channels) && prior.channels.length > 0) {
|
||||||
|
serverObj[server] = Object.assign({}, prior, { online: false });
|
||||||
|
} else {
|
||||||
|
serverObj[server] = {
|
||||||
|
online: false,
|
||||||
|
channels: []
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(statusPromises);
|
await Promise.all(statusPromises);
|
||||||
localStorage.setItem('config', JSON.stringify(config));
|
localStorage.setItem('config', JSON.stringify(config));
|
||||||
|
|
||||||
const existingSession = App.storage.getSession();
|
const before = sessionSignature(App.storage.getSession());
|
||||||
const serverKeys = config.servers.map((serverObj) => Object.keys(serverObj)[0]);
|
App.storage.ensureSessionFromCache();
|
||||||
if (serverKeys.length === 0) return;
|
const after = sessionSignature(App.storage.getSession());
|
||||||
const selectedServerKey = existingSession && serverKeys.includes(existingSession.server)
|
return before !== after;
|
||||||
? existingSession.server
|
};
|
||||||
: serverKeys[0];
|
|
||||||
const serverEntry = config.servers.find((serverObj) => Object.keys(serverObj)[0] === selectedServerKey);
|
|
||||||
const serverData = serverEntry ? serverEntry[selectedServerKey] : null;
|
|
||||||
|
|
||||||
if (serverData && serverData.channels && serverData.channels.length > 0) {
|
// Refreshes server status without blocking; updates the menu and reloads
|
||||||
const prefs = App.storage.getPreferences();
|
// videos only if the refresh actually changed the active selection. Safe to
|
||||||
const serverPrefs = prefs[selectedServerKey] || {};
|
// fire-and-forget during startup so the UI renders from cache immediately.
|
||||||
const preferredChannelId = serverPrefs.channelId;
|
App.storage.refreshServerStatusInBackground = function() {
|
||||||
const channel = App.session.resolveChannelById(serverData, preferredChannelId) || serverData.channels[0];
|
return App.storage.initializeServerStatus()
|
||||||
const savedOptions = serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[channel.id] : null;
|
.then((changed) => {
|
||||||
const options = savedOptions ? App.session.hydrateOptions(channel, savedOptions) : App.session.buildDefaultOptions(channel);
|
if (App.ui && typeof App.ui.renderMenu === 'function') {
|
||||||
|
App.ui.renderMenu();
|
||||||
const sessionData = {
|
}
|
||||||
server: selectedServerKey,
|
if (changed && App.videos && typeof App.videos.resetAndReload === 'function') {
|
||||||
channel: channel,
|
App.videos.resetAndReload();
|
||||||
options: options,
|
}
|
||||||
};
|
})
|
||||||
|
.catch((err) => {
|
||||||
App.storage.setSession(sessionData);
|
console.error('Background status refresh failed:', err);
|
||||||
App.session.savePreference(sessionData);
|
});
|
||||||
}
|
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -16,6 +16,41 @@ App.ui = App.ui || {};
|
|||||||
if (select) select.value = App.storage.getPreferredQuality();
|
if (select) select.value = App.storage.getPreferredQuality();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
App.ui.applyFeedEndBehavior = function() {
|
||||||
|
const select = document.getElementById('feed-end-select');
|
||||||
|
if (select) select.value = App.storage.getFeedEndBehavior();
|
||||||
|
};
|
||||||
|
|
||||||
|
App.ui.applyDensity = function() {
|
||||||
|
const density = App.storage.getDensity();
|
||||||
|
document.body.dataset.density = density;
|
||||||
|
const select = document.getElementById('density-select');
|
||||||
|
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');
|
||||||
@@ -31,61 +66,119 @@ App.ui = App.ui || {};
|
|||||||
}, 4000);
|
}, 4000);
|
||||||
};
|
};
|
||||||
|
|
||||||
App.ui.showInfo = function(video) {
|
// Which video the panel is currently showing, so a slow resolve that lands
|
||||||
|
// after the user moved on doesn't redraw someone else's panel.
|
||||||
|
let infoVideo = null;
|
||||||
|
|
||||||
|
const appendInfoHeading = function(list, label) {
|
||||||
|
const heading = document.createElement('div');
|
||||||
|
heading.className = 'info-section';
|
||||||
|
heading.textContent = label;
|
||||||
|
list.appendChild(heading);
|
||||||
|
};
|
||||||
|
|
||||||
|
// One row per field, whatever the field is. Objects and arrays are printed
|
||||||
|
// 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 count = 0;
|
||||||
|
Object.entries(data || {}).forEach(([key, value]) => {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'info-row';
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'info-label';
|
||||||
|
label.textContent = key;
|
||||||
|
|
||||||
|
let valueNode;
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
valueNode = document.createElement('pre');
|
||||||
|
valueNode.className = 'info-json';
|
||||||
|
valueNode.textContent = JSON.stringify(value, null, 2);
|
||||||
|
} else {
|
||||||
|
valueNode = document.createElement('span');
|
||||||
|
valueNode.className = 'info-value';
|
||||||
|
valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
row.appendChild(label);
|
||||||
|
row.appendChild(valueNode);
|
||||||
|
list.appendChild(row);
|
||||||
|
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');
|
const modal = document.getElementById('info-modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
|
const opts = options || {};
|
||||||
const title = document.getElementById('info-title');
|
const title = document.getElementById('info-title');
|
||||||
const list = document.getElementById('info-list');
|
const list = document.getElementById('info-list');
|
||||||
const empty = document.getElementById('info-empty');
|
const empty = document.getElementById('info-empty');
|
||||||
|
|
||||||
const data = video && video.meta ? video.meta : video;
|
const item = (video && typeof video === 'object') ? video : {};
|
||||||
const titleText = data && data.title ? data.title : 'Video Info';
|
// `meta` is the trimmed playback payload; the full extractor info is a
|
||||||
if (title) title.textContent = titleText;
|
// 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) {
|
if (list) {
|
||||||
list.innerHTML = "";
|
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);
|
||||||
|
|
||||||
let hasRows = false;
|
if (resolved && typeof resolved === 'object') {
|
||||||
if (data && typeof data === 'object') {
|
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
rows += appendInfoRows(list, resolved);
|
||||||
if (!list) return;
|
}
|
||||||
const row = document.createElement('div');
|
|
||||||
row.className = 'info-row';
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
if (opts.pending) {
|
||||||
label.className = 'info-label';
|
const pending = document.createElement('div');
|
||||||
label.textContent = key;
|
pending.className = 'info-pending';
|
||||||
|
pending.textContent = 'Resolving full metadata…';
|
||||||
let valueNode;
|
list.appendChild(pending);
|
||||||
if (value && typeof value === 'object') {
|
}
|
||||||
valueNode = document.createElement('pre');
|
|
||||||
valueNode.className = 'info-json';
|
|
||||||
valueNode.textContent = JSON.stringify(value, null, 2);
|
|
||||||
} else {
|
|
||||||
valueNode = document.createElement('span');
|
|
||||||
valueNode.className = 'info-value';
|
|
||||||
valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.appendChild(label);
|
|
||||||
row.appendChild(valueNode);
|
|
||||||
list.appendChild(row);
|
|
||||||
hasRows = true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
};
|
};
|
||||||
@@ -281,6 +374,47 @@ App.ui = App.ui || {};
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const densitySelect = document.getElementById('density-select');
|
||||||
|
if (densitySelect) {
|
||||||
|
densitySelect.value = App.storage.getDensity();
|
||||||
|
densitySelect.onchange = () => {
|
||||||
|
App.storage.setDensity(densitySelect.value);
|
||||||
|
App.ui.applyDensity();
|
||||||
|
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
||||||
|
App.virtualGrid.relayout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
if (feedEndSelect) {
|
||||||
|
feedEndSelect.value = App.storage.getFeedEndBehavior();
|
||||||
|
feedEndSelect.onchange = () => {
|
||||||
|
App.storage.setFeedEndBehavior(feedEndSelect.value);
|
||||||
|
if (App.feed && typeof App.feed.applyEndBehavior === 'function') {
|
||||||
|
App.feed.applyEndBehavior();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (favoritesToggle) {
|
if (favoritesToggle) {
|
||||||
favoritesToggle.checked = App.favorites.isVisible();
|
favoritesToggle.checked = App.favorites.isVisible();
|
||||||
favoritesToggle.onchange = () => {
|
favoritesToggle.onchange = () => {
|
||||||
@@ -385,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();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -533,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');
|
||||||
|
|||||||
155
frontend/js/version.js
Normal file
155
frontend/js/version.js
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
window.App = window.App || {};
|
||||||
|
App.version = App.version || {};
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
const VERSION_URL = '/api/version';
|
||||||
|
const POLL_INTERVAL_MS = 60000;
|
||||||
|
|
||||||
|
// Baseline manifest captured on startup: { version, files: { rel: hash } }.
|
||||||
|
let baseline = null;
|
||||||
|
let timer = null;
|
||||||
|
// Set once a JS/HTML change is detected; the page reloads at a safe moment.
|
||||||
|
let reloadPending = false;
|
||||||
|
let checking = false;
|
||||||
|
|
||||||
|
async function fetchVersion() {
|
||||||
|
const resp = await fetch(VERSION_URL, { cache: 'no-store' });
|
||||||
|
if (!resp.ok) throw new Error('version fetch failed: ' + resp.status);
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap a stylesheet <link> in place using a cache-busted href so updated CSS
|
||||||
|
// applies instantly. The old link is removed only after the new one loads to
|
||||||
|
// avoid a flash of unstyled content.
|
||||||
|
function hotReloadCss(relPath, hash) {
|
||||||
|
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
||||||
|
const match = links.find((l) => {
|
||||||
|
const href = (l.getAttribute('href') || '').split('?')[0];
|
||||||
|
return href.endsWith(relPath) || href.endsWith('/' + relPath);
|
||||||
|
});
|
||||||
|
if (!match) return false;
|
||||||
|
const base = (match.getAttribute('href') || '').split('?')[0];
|
||||||
|
const fresh = match.cloneNode(false);
|
||||||
|
fresh.setAttribute('href', base + '?v=' + hash);
|
||||||
|
fresh.addEventListener('load', () => { if (match.parentNode) match.remove(); });
|
||||||
|
fresh.addEventListener('error', () => { if (fresh.parentNode) fresh.remove(); });
|
||||||
|
match.parentNode.insertBefore(fresh, match.nextSibling);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diffFiles(oldFiles, newFiles) {
|
||||||
|
const changed = [];
|
||||||
|
const keys = new Set([
|
||||||
|
...Object.keys(oldFiles || {}),
|
||||||
|
...Object.keys(newFiles || {})
|
||||||
|
]);
|
||||||
|
keys.forEach((k) => {
|
||||||
|
if ((oldFiles || {})[k] !== (newFiles || {})[k]) changed.push(k);
|
||||||
|
});
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reload is "safe" when the user isn't mid-playback: no open custom
|
||||||
|
// player, no active reels feed, and no playing <video>. App state survives
|
||||||
|
// a reload because it is restored from localStorage on boot.
|
||||||
|
function isSafeToReload() {
|
||||||
|
if (App.state && App.state.feedOpen) return false;
|
||||||
|
const player = document.getElementById('custom-player');
|
||||||
|
if (player && player.classList.contains('open')) {
|
||||||
|
const video = player.querySelector('.cp-video');
|
||||||
|
if (video && !video.paused && !video.ended) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdateBanner() {
|
||||||
|
const banner = document.getElementById('update-banner');
|
||||||
|
if (!banner) return;
|
||||||
|
banner.classList.add('show');
|
||||||
|
const btn = document.getElementById('update-banner-btn');
|
||||||
|
if (btn) btn.onclick = () => window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryReloadWhenSafe() {
|
||||||
|
if (!reloadPending) return;
|
||||||
|
if (isSafeToReload()) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
showUpdateBanner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(latest) {
|
||||||
|
const changed = diffFiles(baseline.files, latest.files);
|
||||||
|
if (!changed.length) return;
|
||||||
|
|
||||||
|
let needsReload = false;
|
||||||
|
changed.forEach((file) => {
|
||||||
|
if (file.endsWith('.css') && hotReloadCss(file, latest.files[file])) {
|
||||||
|
return; // hot-swapped without reload
|
||||||
|
}
|
||||||
|
// JS and HTML can't be safely live-patched; they require a reload.
|
||||||
|
needsReload = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adopt the new manifest so we don't re-trigger on the same change.
|
||||||
|
baseline = latest;
|
||||||
|
|
||||||
|
if (needsReload) {
|
||||||
|
reloadPending = true;
|
||||||
|
tryReloadWhenSafe();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
if (checking || !baseline) return;
|
||||||
|
checking = true;
|
||||||
|
try {
|
||||||
|
const latest = await fetchVersion();
|
||||||
|
apply(latest);
|
||||||
|
} catch (e) {
|
||||||
|
// Network blips are non-fatal; we retry on the next tick.
|
||||||
|
} finally {
|
||||||
|
checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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() {
|
||||||
|
try {
|
||||||
|
baseline = await fetchVersion();
|
||||||
|
} catch (e) {
|
||||||
|
return; // endpoint unavailable; skip version checking entirely
|
||||||
|
}
|
||||||
|
timer = setInterval(check, POLL_INTERVAL_MS);
|
||||||
|
// Check promptly when the user returns to the tab so updates land while
|
||||||
|
// they were away, and retry a pending reload once playback stops.
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
tryReloadWhenSafe();
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Re-attempt a deferred reload whenever a video finishes/pauses. The
|
||||||
|
// custom player's <video> is torn down and rebuilt on every open(), so
|
||||||
|
// bind on the capture phase at the document level instead of to a
|
||||||
|
// specific element (media events don't bubble, but capture still sees
|
||||||
|
// 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
1
media_srv2.log
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/bin/bash: line 1: cd: too many arguments
|
||||||
Reference in New Issue
Block a user