Compare commits
6 Commits
52d7802491
...
thumbnail-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b719b2ea | ||
|
|
e2632c962d | ||
|
|
0009574b77 | ||
|
|
d4ed9dce5d | ||
|
|
c54d0889c1 | ||
|
|
b48d7aa161 |
219
backend/main.py
219
backend/main.py
@@ -10,6 +10,7 @@ import yt_dlp
|
|||||||
from yt_dlp.networking.impersonate import ImpersonateTarget
|
from yt_dlp.networking.impersonate import ImpersonateTarget
|
||||||
from curl_cffi import requests as impersonate_requests
|
from curl_cffi import requests as impersonate_requests
|
||||||
import threading
|
import threading
|
||||||
|
import queue
|
||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -20,18 +21,75 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def _is_tls_verify_error(err):
|
||||||
@@ -54,22 +112,34 @@ def impersonate_get(url, **kwargs):
|
|||||||
stream we retry once with verification off, and say so in the log. Set
|
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."""
|
STREAM_TLS_VERIFY_ONLY=1 to keep the hard failure instead."""
|
||||||
host = urllib.parse.urlparse(url).netloc
|
host = urllib.parse.urlparse(url).netloc
|
||||||
|
sess = _borrow_session()
|
||||||
if host in _tls_unverified_hosts:
|
if host in _tls_unverified_hosts:
|
||||||
return get_impersonate_session().get(url, verify=False, **kwargs)
|
try:
|
||||||
|
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
|
||||||
|
except Exception:
|
||||||
|
_discard_session(sess)
|
||||||
|
raise
|
||||||
try:
|
try:
|
||||||
return get_impersonate_session().get(url, **kwargs)
|
return _release_when_closed(sess.get(url, **kwargs), sess)
|
||||||
except Exception as err:
|
except Exception as err:
|
||||||
strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
strict = os.getenv('STREAM_TLS_VERIFY_ONLY', '').strip().lower() in ('1', 'true', 'yes', 'on')
|
||||||
if strict or not _is_tls_verify_error(err):
|
if strict or not _is_tls_verify_error(err):
|
||||||
|
_discard_session(sess)
|
||||||
raise
|
raise
|
||||||
app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err)
|
app.logger.warning("[stream] TLS verification failed for %s (%s); retrying unverified", url, err)
|
||||||
_tls_unverified_hosts.add(host)
|
_tls_unverified_hosts.add(host)
|
||||||
return get_impersonate_session().get(url, verify=False, **kwargs)
|
try:
|
||||||
|
return _release_when_closed(sess.get(url, verify=False, **kwargs), sess)
|
||||||
|
except Exception:
|
||||||
|
_discard_session(sess)
|
||||||
|
raise
|
||||||
|
|
||||||
# Stream params that have dedicated meaning and must never be treated as headers.
|
# Request params that have dedicated meaning and must never be treated as headers.
|
||||||
# `referer` is mapped to a real Referer header by collect_passthrough_headers, but
|
# `referer` is mapped to a real Referer header by collect_passthrough_headers, but
|
||||||
# `live` is purely a playback hint and must not leak upstream as a header.
|
# `live` is purely a playback hint and must not leak upstream as a header. `full`
|
||||||
STREAM_RESERVED_PARAMS = {'url', 'live'}
|
# is /api/resolve's "give me everything" switch and is likewise ours, not the
|
||||||
|
# origin's.
|
||||||
|
STREAM_RESERVED_PARAMS = {'url', 'live', 'full'}
|
||||||
# Headers that affect the transport layer rather than the resource itself; allowing
|
# Headers that affect the transport layer rather than the resource itself; allowing
|
||||||
# these to be forwarded could enable request smuggling or vhost-routing abuse.
|
# these to be forwarded could enable request smuggling or vhost-routing abuse.
|
||||||
STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'}
|
STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'}
|
||||||
@@ -80,6 +150,10 @@ STREAM_IMPERSONATION_MANAGED_HEADERS = {
|
|||||||
'user-agent', 'accept', 'accept-encoding', 'accept-language',
|
'user-agent', 'accept', 'accept-encoding', 'accept-language',
|
||||||
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform',
|
'sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform',
|
||||||
}
|
}
|
||||||
|
# `Content-Range: bytes 0-0/12345` -> the total size of the resource. A '*'
|
||||||
|
# total (an origin that won't say) deliberately doesn't match, so the length is
|
||||||
|
# then simply left out rather than guessed at.
|
||||||
|
_CONTENT_RANGE_TOTAL_RE = re.compile(r'^\s*bytes\s+\d+-\d+/(\d+)\s*$', re.I)
|
||||||
# RFC 7230 token charset for header field-names.
|
# RFC 7230 token charset for header field-names.
|
||||||
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
|
||||||
# Reject control characters (CR/LF/NUL etc.) that could be used for header injection.
|
# Reject control characters (CR/LF/NUL etc.) that could be used for header injection.
|
||||||
@@ -217,8 +291,31 @@ _resolve_cache_lock = threading.Lock()
|
|||||||
# Per-format fields the frontend needs to rank formats and build stream/probe
|
# Per-format fields the frontend needs to rank formats and build stream/probe
|
||||||
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
|
# URLs (see resolveStreamSources/rankFormats in videos.js). Everything else in a
|
||||||
# yt-dlp format dict is dropped to keep the payload small.
|
# yt-dlp format dict is dropped to keep the payload small.
|
||||||
|
# `protocol` is what yt-dlp calls the delivery method ('https', 'm3u8_native',
|
||||||
|
# 'http_dash_segments', ...). Passing it on saves the player a HEAD round trip
|
||||||
|
# against the proxy -- and with it a whole upstream connection -- for URLs whose
|
||||||
|
# extension doesn't say what they are, which is most signed CDN links.
|
||||||
_RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
|
_RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
|
||||||
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality')
|
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality',
|
||||||
|
'protocol', 'format_note')
|
||||||
|
|
||||||
|
|
||||||
|
def _trim_resolve_info(info):
|
||||||
|
"""The lean payload playback needs: the media URLs, the headers that make
|
||||||
|
them work, and just enough per-format detail to rank them. This is what
|
||||||
|
every hovered card asks for, so it stays small."""
|
||||||
|
formats = []
|
||||||
|
for fmt in ((info.get('formats') if info else None) or []):
|
||||||
|
if not fmt.get('url'):
|
||||||
|
continue
|
||||||
|
formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'url': info.get('url') if info else None,
|
||||||
|
'http_headers': (info.get('http_headers') if info else None) or {},
|
||||||
|
'isLive': bool(info.get('is_live')) if info else False,
|
||||||
|
'formats': formats,
|
||||||
|
}
|
||||||
|
|
||||||
# Some channels surface pages that yt-dlp can't extract because the video is
|
# Some channels surface pages that yt-dlp can't extract because the video is
|
||||||
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
|
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
|
||||||
@@ -233,8 +330,11 @@ def resolve_unsupported_embed(page_url):
|
|||||||
"""Best-effort resolver for iframe-embedded JS players yt-dlp can't handle.
|
"""Best-effort resolver for iframe-embedded JS players yt-dlp can't handle.
|
||||||
Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose
|
Returns an info-like dict ({'url','formats','http_headers','is_live'}) whose
|
||||||
single format is the embed's HLS playlist, or None if nothing was found."""
|
single format is the embed's HLS playlist, or None if nothing was found."""
|
||||||
|
# Both fetches are small and fully buffered, so this holds one pooled session
|
||||||
|
# for the whole scrape rather than going through impersonate_get (whose
|
||||||
|
# release is tied to closing a streamed response).
|
||||||
|
sess = _borrow_session()
|
||||||
try:
|
try:
|
||||||
sess = get_impersonate_session()
|
|
||||||
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
|
page = sess.get(page_url, headers={'Referer': page_url}, timeout=15)
|
||||||
embed_url = None
|
embed_url = None
|
||||||
for src in _EMBED_IFRAME_RE.findall(page.text):
|
for src in _EMBED_IFRAME_RE.findall(page.text):
|
||||||
@@ -262,14 +362,25 @@ def resolve_unsupported_embed(page_url):
|
|||||||
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
|
'formats': [{'url': stream_url, 'ext': 'm3u8', 'protocol': 'm3u8', 'http_headers': headers}],
|
||||||
}
|
}
|
||||||
except Exception:
|
except Exception:
|
||||||
|
_discard_session(sess)
|
||||||
|
sess = None
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
if sess is not None:
|
||||||
|
_return_session(sess)
|
||||||
|
|
||||||
@app.route('/api/resolve', methods=['POST', 'GET'])
|
@app.route('/api/resolve', methods=['POST', 'GET'])
|
||||||
def resolve_video():
|
def resolve_video():
|
||||||
"""Resolve a page URL to its playable formats via yt-dlp and return them as
|
"""Resolve a page URL to its playable formats via yt-dlp and return them as
|
||||||
JSON. The frontend calls this on demand (when a card is hovered or scrolled
|
JSON. The frontend calls this on demand (when a card is hovered or scrolled
|
||||||
into view) to learn the real media URLs so it can background-probe them for
|
into view) to learn the real media URLs so it can background-probe them for
|
||||||
direct, proxy-free playability."""
|
direct, proxy-free playability.
|
||||||
|
|
||||||
|
`full=1` returns the extractor's whole info dict instead of the trimmed
|
||||||
|
playback payload -- everything it knows about the video (description, dates,
|
||||||
|
counts, tags, thumbnails, every format field), which is what the Show info
|
||||||
|
panel exists to display. Both views come from one extraction and one cache
|
||||||
|
entry, so asking for the full one costs no extra work upstream."""
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
source = request.json or {}
|
source = request.json or {}
|
||||||
video_url = source.get('url')
|
video_url = source.get('url')
|
||||||
@@ -280,11 +391,19 @@ def resolve_video():
|
|||||||
if not video_url:
|
if not video_url:
|
||||||
return jsonify({"error": "No URL provided"}), 400
|
return jsonify({"error": "No URL provided"}), 400
|
||||||
|
|
||||||
|
want_full = str(source.get('full', '')).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||||
|
|
||||||
|
def view_of(info):
|
||||||
|
if not want_full:
|
||||||
|
return _trim_resolve_info(info)
|
||||||
|
# Nothing to show, but answer in the same shape rather than `null`.
|
||||||
|
return info if info else {}
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with _resolve_cache_lock:
|
with _resolve_cache_lock:
|
||||||
cached = _resolve_cache.get(video_url)
|
cached = _resolve_cache.get(video_url)
|
||||||
if cached and cached[0] > now:
|
if cached and cached[0] > now:
|
||||||
return jsonify(cached[1])
|
return jsonify(view_of(cached[1]))
|
||||||
|
|
||||||
ydl_opts = {
|
ydl_opts = {
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
@@ -301,6 +420,10 @@ def resolve_video():
|
|||||||
try:
|
try:
|
||||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
info = ydl.extract_info(video_url, download=False)
|
info = ydl.extract_info(video_url, download=False)
|
||||||
|
# The raw info dict holds objects that don't survive JSON (and
|
||||||
|
# internal `__`-prefixed bookkeeping). This is the same pass yt-dlp
|
||||||
|
# itself runs behind --dump-json.
|
||||||
|
info = ydl.sanitize_info(info, remove_private_keys=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Many channels point at sites yt-dlp can't extract ("Unsupported URL").
|
# Many channels point at sites yt-dlp can't extract ("Unsupported URL").
|
||||||
# That's not fatal here -- the embed fallback below may still find a
|
# That's not fatal here -- the embed fallback below may still find a
|
||||||
@@ -315,26 +438,17 @@ def resolve_video():
|
|||||||
if embed:
|
if embed:
|
||||||
info = embed
|
info = embed
|
||||||
|
|
||||||
formats = []
|
# The extraction is cached whole, and each caller is served the view it
|
||||||
for fmt in ((info.get('formats') if info else None) or []):
|
# asked for. A failed extraction (info is None) is cached the same way, so a
|
||||||
if not fmt.get('url'):
|
# video that can't be resolved is attempted once per TTL rather than on
|
||||||
continue
|
# every hover.
|
||||||
formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None})
|
|
||||||
|
|
||||||
result = {
|
|
||||||
'url': info.get('url') if info else None,
|
|
||||||
'http_headers': (info.get('http_headers') if info else None) or {},
|
|
||||||
'isLive': bool(info.get('is_live')) if info else False,
|
|
||||||
'formats': formats,
|
|
||||||
}
|
|
||||||
|
|
||||||
with _resolve_cache_lock:
|
with _resolve_cache_lock:
|
||||||
# Drop expired entries so the cache doesn't grow without bound.
|
# Drop expired entries so the cache doesn't grow without bound.
|
||||||
for key in [k for k, v in _resolve_cache.items() if v[0] <= now]:
|
for key in [k for k, v in _resolve_cache.items() if v[0] <= now]:
|
||||||
_resolve_cache.pop(key, None)
|
_resolve_cache.pop(key, None)
|
||||||
_resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, result)
|
_resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, info)
|
||||||
|
|
||||||
return jsonify(result)
|
return jsonify(view_of(info))
|
||||||
|
|
||||||
@app.route('/api/image', methods=['GET', 'HEAD'])
|
@app.route('/api/image', methods=['GET', 'HEAD'])
|
||||||
def image_proxy():
|
def image_proxy():
|
||||||
@@ -634,6 +748,15 @@ 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']
|
||||||
|
|
||||||
|
# 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)
|
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
|
||||||
@@ -694,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:
|
||||||
|
|||||||
@@ -398,6 +398,15 @@ body.theme-light .sidebar {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Explanatory line under a control -- and where the import reports back. Not a
|
||||||
|
`label`, so it keeps sentence case and normal letter spacing. */
|
||||||
|
.setting-note {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.setting-label-row label {
|
.setting-label-row label {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -766,6 +775,35 @@ body.theme-light .setting-item select option {
|
|||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.favorites-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-sort {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: var(--radius-sm, 6px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorites-actions .btn-secondary {
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Browsing favorites as a grid: the bar above it would be the same list twice,
|
||||||
|
so it collapses to its header (which carries the way back out). */
|
||||||
|
body.favorites-view-open .favorites-list,
|
||||||
|
body.favorites-view-open .favorites-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-list {
|
.favorites-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -819,6 +857,8 @@ body.theme-light .setting-item select option {
|
|||||||
padding: 10px 12px 12px 12px;
|
padding: 10px 12px 12px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One line, always. A title too long for the card scrolls across it (see
|
||||||
|
App.marquee) rather than wrapping the card to an uneven height. */
|
||||||
.favorite-info h4 {
|
.favorite-info h4 {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -826,6 +866,20 @@ body.theme-light .setting-item select option {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-title-text {
|
||||||
|
display: inline-block;
|
||||||
|
padding-right: 24px;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.favorite-card.is-title-active .favorite-title-text {
|
||||||
|
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorites-empty {
|
.favorites-empty {
|
||||||
@@ -1349,6 +1403,31 @@ body.theme-light .favorite-btn {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The panel shows every field the client has, which for a resolved video is the
|
||||||
|
extractor's whole payload -- dozens of rows. Give the list its own scroll so
|
||||||
|
the card stays inside the viewport and the close button stays put. */
|
||||||
|
.info-list {
|
||||||
|
max-height: min(70vh, 620px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-pending {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.info-row {
|
.info-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
@@ -2124,7 +2203,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
|||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-title.is-marquee .feed-title-text {
|
.feed-title.has-marquee .feed-title-text {
|
||||||
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
|
animation: video-title-marquee var(--marquee-duration, 10s) linear infinite;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@
|
|||||||
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
|
<section id="favorites-bar" class="favorites-bar" aria-label="Favorites">
|
||||||
<div class="favorites-header">
|
<div class="favorites-header">
|
||||||
<h3>Favorites</h3>
|
<h3>Favorites</h3>
|
||||||
|
<div class="favorites-actions">
|
||||||
|
<select id="favorites-sort" class="favorites-sort" aria-label="Sort favorites"></select>
|
||||||
|
<button id="favorites-browse-btn" class="btn-secondary" type="button" aria-pressed="false">Browse all</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="favorites-list" class="favorites-list"></div>
|
<div id="favorites-list" class="favorites-list"></div>
|
||||||
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
|
<div id="favorites-empty" class="favorites-empty">No favorites yet. Tap the heart on a video to save it here.</div>
|
||||||
@@ -133,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>
|
||||||
|
|
||||||
@@ -187,10 +202,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="static/js/state.js"></script>
|
<script src="static/js/state.js"></script>
|
||||||
|
<script src="static/js/marquee.js"></script>
|
||||||
<script src="static/js/storage.js"></script>
|
<script src="static/js/storage.js"></script>
|
||||||
<script src="static/js/customPlayer.js"></script>
|
<script src="static/js/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>
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ App.enhance = App.enhance || {};
|
|||||||
const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
|
const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
// Not resolved yet: kick it off so the *next* hover can preview.
|
// Not resolved yet: kick it off so the *next* hover can preview.
|
||||||
if (typeof App.videos.resolveAndProbe === 'function') App.videos.resolveAndProbe(v);
|
if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let url = '';
|
let url = '';
|
||||||
@@ -151,6 +151,26 @@ App.enhance = App.enhance || {};
|
|||||||
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
|
if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout();
|
||||||
}});
|
}});
|
||||||
out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } });
|
out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } });
|
||||||
|
|
||||||
|
// The favorites bar carries these controls, but it can be switched
|
||||||
|
// off in settings -- in which case the palette is the way in.
|
||||||
|
if (App.favoritesView && App.favorites) {
|
||||||
|
const browsing = App.favoritesView.isActive();
|
||||||
|
out.push({
|
||||||
|
label: browsing ? 'Back to videos' : 'Browse favorites',
|
||||||
|
hint: browsing ? 'Leave the favorites grid' : 'All favorites as a grid',
|
||||||
|
run: () => App.favoritesView.toggle()
|
||||||
|
});
|
||||||
|
const currentSort = App.favorites.getSort();
|
||||||
|
App.favorites.SORTS.forEach((sort) => {
|
||||||
|
if (sort.id === currentSort) return;
|
||||||
|
out.push({
|
||||||
|
label: `Sort favorites: ${sort.label}`,
|
||||||
|
hint: 'Favorites',
|
||||||
|
run: () => App.favoritesView.applySort(sort.id)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
|
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
|
||||||
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
|
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
|
||||||
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
|
||||||
|
|||||||
@@ -10,23 +10,82 @@ App.favorites = App.favorites || {};
|
|||||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||||
const parsed = raw ? JSON.parse(raw) : [];
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
if (!Array.isArray(parsed)) return [];
|
if (!Array.isArray(parsed)) return [];
|
||||||
// Favorites saved by older versions carry a `meta` blob of resolved
|
// Two things are repaired on the way in, and written back once if
|
||||||
// formats whose URLs are signed and long expired. Drop it on the way
|
// anything changed, so the fix happens exactly one time:
|
||||||
// in so no code path can reach for one; everything re-resolves from
|
//
|
||||||
// `url` at play time, and normalize() no longer stores it.
|
// `meta`, from older versions, is a blob of resolved formats whose
|
||||||
return parsed.map((item) => {
|
// URLs are signed and long expired -- dropped so no code path can
|
||||||
if (item && typeof item === 'object' && item.meta) {
|
// reach for one; everything re-resolves from `url` at play time.
|
||||||
const clean = Object.assign({}, item);
|
//
|
||||||
delete clean.meta;
|
// `favoriteDate` didn't exist before sorting needed it. There's no
|
||||||
return clean;
|
// way to recover when an old favorite was actually saved, so it
|
||||||
}
|
// gets now: they sort together, as one batch, at the point the
|
||||||
return item;
|
// 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));
|
||||||
};
|
};
|
||||||
@@ -53,7 +112,10 @@ App.favorites = App.favorites || {};
|
|||||||
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)),
|
||||||
|
// 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
|
// No `meta` field: persisting resolved formats would freeze their
|
||||||
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
|
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
|
||||||
// favorite look like a fresh, unresolved listing item again, so
|
// favorite look like a fresh, unresolved listing item again, so
|
||||||
@@ -63,10 +125,90 @@ App.favorites = App.favorites || {};
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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';
|
||||||
};
|
};
|
||||||
@@ -85,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)));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +240,9 @@ App.favorites = App.favorites || {};
|
|||||||
const key = App.favorites.getKey(video);
|
const key = App.favorites.getKey(video);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
const favorites = App.favorites.getAll();
|
const favorites = App.favorites.getAll();
|
||||||
const existingIndex = favorites.findIndex((item) => item.key === key);
|
// By key or by URL: unfavoriting a card whose video came in from a
|
||||||
|
// backup must remove that entry, not add a second one beside it.
|
||||||
|
const existingIndex = App.favorites.indexOfEntry(favorites, video);
|
||||||
const becameFavorite = existingIndex < 0;
|
const becameFavorite = existingIndex < 0;
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
favorites.splice(existingIndex, 1);
|
favorites.splice(existingIndex, 1);
|
||||||
@@ -118,18 +264,117 @@ App.favorites = App.favorites || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The bar is a horizontal strip, and a long favorites list is hundreds of
|
||||||
|
// cards. Only a screenful or so is built up front; the rest arrives as the
|
||||||
|
// strip is scrolled, which keeps opening the app cheap no matter how many
|
||||||
|
// favorites are saved (an import can add hundreds at once).
|
||||||
|
const BAR_PAGE_SIZE = 24;
|
||||||
|
// How close to the right end the strip has to get before the next page is
|
||||||
|
// appended -- roughly a screen's worth of cards ahead of the reader.
|
||||||
|
const BAR_PAGE_AHEAD_PX = 800;
|
||||||
|
const barPage = { items: [], rendered: 0 };
|
||||||
|
|
||||||
|
// Bar titles are one line that scrolls when it doesn't fit, the same as the
|
||||||
|
// grid card's. Which one scrolls follows the grid too: with a real pointer
|
||||||
|
// it's the card under it, on touch the card nearest the middle of the strip.
|
||||||
|
// Animating every overflowing title at once turns the bar into a wall of
|
||||||
|
// moving text.
|
||||||
|
const barTitleEnv = {
|
||||||
|
useHoverFocus: window.matchMedia('(hover: hover) and (pointer: fine)').matches
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBarTitleActive = function(card, active) {
|
||||||
|
const title = card && card.querySelector('.favorite-title');
|
||||||
|
if (!title) return;
|
||||||
|
card.classList.toggle('is-title-active', !!active && title.classList.contains('has-marquee'));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch: the card nearest the centre of the visible strip is the one being
|
||||||
|
// read, so it is the one whose title scrolls.
|
||||||
|
const syncBarTitleActive = function(list) {
|
||||||
|
if (!list) return;
|
||||||
|
const listRect = list.getBoundingClientRect();
|
||||||
|
const centre = listRect.left + listRect.width / 2;
|
||||||
|
let best = null;
|
||||||
|
let bestDistance = Infinity;
|
||||||
|
const cards = list.querySelectorAll('.favorite-card');
|
||||||
|
cards.forEach((card) => {
|
||||||
|
const rect = card.getBoundingClientRect();
|
||||||
|
if (rect.right <= listRect.left || rect.left >= listRect.right) return;
|
||||||
|
const distance = Math.abs((rect.left + rect.width / 2) - centre);
|
||||||
|
if (distance < bestDistance) {
|
||||||
|
bestDistance = distance;
|
||||||
|
best = card;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cards.forEach((card) => setBarTitleActive(card, card === best));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Measures every card in the strip. Cheap enough to redo wholesale: a card's
|
||||||
|
// width is fixed, so this only really runs when cards are added.
|
||||||
|
const measureBarTitles = function(list) {
|
||||||
|
const target = list || document.getElementById('favorites-list');
|
||||||
|
if (!target) return;
|
||||||
|
target.querySelectorAll('.favorite-card').forEach((card) => {
|
||||||
|
App.marquee.measure(card.querySelector('.favorite-title'),
|
||||||
|
card.querySelector('.favorite-title-text'));
|
||||||
|
});
|
||||||
|
if (!barTitleEnv.useHoverFocus) syncBarTitleActive(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The display font arrives after the first render, and it changes how wide
|
||||||
|
// every title is -- so whatever was measured against the fallback font has
|
||||||
|
// to be measured again once the real one is in.
|
||||||
|
if (document.fonts && document.fonts.ready) {
|
||||||
|
document.fonts.ready.then(() => measureBarTitles()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
App.favorites.renderBar = function() {
|
App.favorites.renderBar = function() {
|
||||||
const bar = document.getElementById('favorites-bar');
|
const bar = document.getElementById('favorites-bar');
|
||||||
const list = document.getElementById('favorites-list');
|
const list = document.getElementById('favorites-list');
|
||||||
const empty = document.getElementById('favorites-empty');
|
const empty = document.getElementById('favorites-empty');
|
||||||
if (!bar || !list) return;
|
if (!bar || !list) return;
|
||||||
|
|
||||||
const favorites = App.favorites.getAll();
|
const favorites = App.favorites.sorted();
|
||||||
const visible = App.favorites.isVisible();
|
// While the favorites grid is open the bar is kept mounted even if it's
|
||||||
bar.style.display = visible ? 'block' : 'none';
|
// switched off in settings: its header is what leads back out.
|
||||||
|
const browsing = !!(App.favoritesView && App.favoritesView.isActive());
|
||||||
|
bar.style.display = (App.favorites.isVisible() || browsing) ? 'block' : 'none';
|
||||||
|
|
||||||
list.innerHTML = "";
|
list.innerHTML = "";
|
||||||
favorites.forEach((item) => {
|
barPage.items = favorites;
|
||||||
|
barPage.rendered = 0;
|
||||||
|
// While the favorites grid is open the strip is hidden -- the grid is
|
||||||
|
// the same list, larger -- so don't build cards nobody can see. The
|
||||||
|
// header stays, because it carries the way back out.
|
||||||
|
if (!browsing) appendBarPage(list);
|
||||||
|
|
||||||
|
// Assignment rather than addEventListener: renderBar runs on every
|
||||||
|
// favorite change, and this must not stack up handlers.
|
||||||
|
let scrollRaf = null;
|
||||||
|
list.onscroll = () => {
|
||||||
|
// Which card is centred changes as the strip moves, but only once
|
||||||
|
// per frame is worth measuring.
|
||||||
|
if (!barTitleEnv.useHoverFocus && !scrollRaf) {
|
||||||
|
scrollRaf = requestAnimationFrame(() => {
|
||||||
|
scrollRaf = null;
|
||||||
|
syncBarTitleActive(list);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (barPage.rendered >= barPage.items.length) return;
|
||||||
|
const remaining = list.scrollWidth - (list.scrollLeft + list.clientWidth);
|
||||||
|
if (remaining <= BAR_PAGE_AHEAD_PX) appendBarPage(list);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (empty) {
|
||||||
|
empty.style.display = favorites.length > 0 ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function appendBarPage(list) {
|
||||||
|
const slice = barPage.items.slice(barPage.rendered, barPage.rendered + BAR_PAGE_SIZE);
|
||||||
|
barPage.rendered += slice.length;
|
||||||
|
slice.forEach((item) => {
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'favorite-card';
|
card.className = 'favorite-card';
|
||||||
card.dataset.favKey = item.key;
|
card.dataset.favKey = item.key;
|
||||||
@@ -140,14 +385,14 @@ App.favorites = App.favorites || {};
|
|||||||
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
const liveBadge = item.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
${liveBadge}
|
${liveBadge}
|
||||||
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}">♥</button>
|
<button class="favorite-btn is-favorite" type="button" aria-pressed="true" aria-label="Remove from favorites" data-fav-key="${item.key}" data-fav-url="${item.url || ''}">♥</button>
|
||||||
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
||||||
<div class="video-menu" role="menu">
|
<div class="video-menu" role="menu">
|
||||||
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
||||||
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-thumb">
|
<div class="video-thumb">
|
||||||
<img src="${item.thumb}" alt="${item.title}" loading="lazy" decoding="async">
|
<img alt="${item.title}" loading="lazy" decoding="async">
|
||||||
<div class="video-loading" aria-hidden="true">
|
<div class="video-loading" aria-hidden="true">
|
||||||
<div class="video-loading-spinner"></div>
|
<div class="video-loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,12 +400,12 @@ App.favorites = App.favorites || {};
|
|||||||
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="favorite-info">
|
<div class="favorite-info">
|
||||||
<h4>${item.title}</h4>
|
<h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const thumb = card.querySelector('img');
|
const thumb = card.querySelector('img');
|
||||||
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') {
|
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
||||||
App.videos.attachNoReferrerRetry(thumb);
|
App.videos.attachThumbnail(thumb, item.thumb);
|
||||||
}
|
}
|
||||||
card.onclick = () => {
|
card.onclick = () => {
|
||||||
if (card.classList.contains('is-loading')) return;
|
if (card.classList.contains('is-loading')) return;
|
||||||
@@ -191,9 +436,10 @@ App.favorites = App.favorites || {};
|
|||||||
showInfoBtn.onclick = (event) => {
|
showInfoBtn.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.videos.closeAllMenus();
|
App.videos.closeAllMenus();
|
||||||
// Favorites deliberately store no resolved metadata, so pull
|
// Favorites deliberately store no resolved metadata; the
|
||||||
// it fresh before showing the full info dump.
|
// panel opens on what the entry holds and resolves the rest
|
||||||
App.videos.ensureFormats(item).then(() => App.ui.showInfo(item));
|
// itself.
|
||||||
|
App.ui.openInfo(item);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (downloadBtn) {
|
if (downloadBtn) {
|
||||||
@@ -213,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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -159,26 +159,11 @@ App.feed = App.feed || {};
|
|||||||
slide.classList.remove('is-loaded');
|
slide.classList.remove('is-loaded');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Single-line feed title that scrolls horizontally when it overflows.
|
// Single-line slide title that scrolls when it overflows. Only the active
|
||||||
// Driven off the overflow distance so every title scrolls at the same
|
// slide's title is measured, so like the player it always scrolls.
|
||||||
// gentle speed, matching the grid-card marquee (see App.videos.measureTitle).
|
|
||||||
const measureFeedTitle = function(slide) {
|
const measureFeedTitle = function(slide) {
|
||||||
if (!slide) return;
|
if (!slide) return;
|
||||||
const wrap = slide.querySelector('.feed-title');
|
App.marquee.measure(slide.querySelector('.feed-title'), slide.querySelector('.feed-title-text'));
|
||||||
const text = slide.querySelector('.feed-title-text');
|
|
||||||
if (!wrap || !text) return;
|
|
||||||
const overflow = text.scrollWidth - wrap.clientWidth;
|
|
||||||
if (overflow > 4) {
|
|
||||||
const distance = overflow + 16;
|
|
||||||
const MARQUEE_SPEED = 28; // px per second
|
|
||||||
const duration = Math.max(6, distance / MARQUEE_SPEED);
|
|
||||||
text.style.setProperty('--marquee-distance', `${distance}px`);
|
|
||||||
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
|
||||||
wrap.classList.add('is-marquee');
|
|
||||||
} else {
|
|
||||||
wrap.classList.remove('is-marquee');
|
|
||||||
text.style.removeProperty('--marquee-distance');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setTimelinePosition = function(slide, ratio) {
|
const setTimelinePosition = function(slide, ratio) {
|
||||||
@@ -365,7 +350,7 @@ App.feed = App.feed || {};
|
|||||||
// What's actually on screen, so the quality menu can tick it.
|
// What's actually on screen, so the quality menu can tick it.
|
||||||
slide._activeUrl = resolved.url;
|
slide._activeUrl = resolved.url;
|
||||||
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
|
||||||
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
|
const isHls = App.videos.classifySource(resolved).isHls;
|
||||||
|
|
||||||
video.muted = state.feedMuted;
|
video.muted = state.feedMuted;
|
||||||
video.preload = 'auto';
|
video.preload = 'auto';
|
||||||
@@ -439,10 +424,10 @@ App.feed = App.feed || {};
|
|||||||
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
const liveBadge = v.isLive ? '<span class="live-badge feed-live-badge">● LIVE</span>' : '';
|
||||||
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
const favKey = App.favorites ? App.favorites.getKey(v) : null;
|
||||||
slide.innerHTML = `
|
slide.innerHTML = `
|
||||||
<img class="feed-poster" src="${v.thumb || ''}" alt="" loading="lazy" decoding="async">
|
<img class="feed-poster" alt="" loading="lazy" decoding="async">
|
||||||
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
|
<video class="feed-video" muted playsinline webkit-playsinline preload="none"></video>
|
||||||
${liveBadge}
|
${liveBadge}
|
||||||
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}"></button>` : ''}
|
${favKey ? `<button class="favorite-btn feed-fav-btn" type="button" data-fav-key="${favKey}" data-fav-url="${v.url || ''}"></button>` : ''}
|
||||||
<button class="cp-pip-btn feed-pip-btn" type="button" aria-label="Picture in picture" hidden>
|
<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="">
|
<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>
|
||||||
@@ -461,7 +446,7 @@ App.feed = App.feed || {};
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const poster = slide.querySelector('.feed-poster');
|
const poster = slide.querySelector('.feed-poster');
|
||||||
App.videos.attachNoReferrerRetry(poster);
|
App.videos.attachThumbnail(poster, v.thumb);
|
||||||
const slideVideo = slide.querySelector('.feed-video');
|
const slideVideo = slide.querySelector('.feed-video');
|
||||||
bindTimeline(slide, slideVideo);
|
bindTimeline(slide, slideVideo);
|
||||||
bindSharedControls(slide, slideVideo, v);
|
bindSharedControls(slide, slideVideo, v);
|
||||||
@@ -484,7 +469,7 @@ App.feed = App.feed || {};
|
|||||||
|
|
||||||
const favBtn = slide.querySelector('.feed-fav-btn');
|
const favBtn = slide.querySelector('.feed-fav-btn');
|
||||||
if (favBtn && App.favorites) {
|
if (favBtn && App.favorites) {
|
||||||
App.favorites.setButtonState(favBtn, App.favorites.getSet().has(favKey));
|
App.favorites.setButtonState(favBtn, App.favorites.indexOfEntry(App.favorites.getAll(), v) >= 0);
|
||||||
favBtn.addEventListener('click', (event) => {
|
favBtn.addEventListener('click', (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.favorites.toggle(v);
|
App.favorites.toggle(v);
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
})();
|
||||||
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;
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -25,9 +25,40 @@ App.player = App.player || {};
|
|||||||
originEl: null,
|
originEl: null,
|
||||||
hudHovered: false, // mouse resting on the controls (desktop)
|
hudHovered: false, // mouse resting on the controls (desktop)
|
||||||
activeUrl: '', // media URL actually playing, for the format menu's tick
|
activeUrl: '', // media URL actually playing, for the format menu's tick
|
||||||
attemptToken: 0 // bumps on every open()/format switch to void stale async callbacks
|
attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks
|
||||||
|
fetchAbort: null // aborts the current attempt's own requests
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Stops everything the current attempt has in flight. The token guards keep
|
||||||
|
// stale *callbacks* from acting, but they don't stop the requests those
|
||||||
|
// callbacks were waiting on: hls.js goes on pulling segments through the
|
||||||
|
// proxy, the media element keeps its connection open, and the content-type
|
||||||
|
// sniff keeps a whole upstream fetch alive on the server. When an attempt is
|
||||||
|
// superseded -- most of all when the direct route wins the race and the
|
||||||
|
// proxy has nothing left to do -- that work is pure waste at both ends.
|
||||||
|
function cancelInFlight() {
|
||||||
|
if (cp.fetchAbort) {
|
||||||
|
cp.fetchAbort.abort();
|
||||||
|
cp.fetchAbort = null;
|
||||||
|
}
|
||||||
|
if (state.hlsPlayer) {
|
||||||
|
state.hlsPlayer.stopLoad();
|
||||||
|
state.hlsPlayer.detachMedia();
|
||||||
|
state.hlsPlayer.destroy();
|
||||||
|
state.hlsPlayer = null;
|
||||||
|
}
|
||||||
|
const video = cp.video;
|
||||||
|
if (video) {
|
||||||
|
video.onerror = null;
|
||||||
|
video.pause();
|
||||||
|
// Dropping the source is what closes the connection the media
|
||||||
|
// element is holding; load() makes the element let go of it now
|
||||||
|
// rather than whenever it next feels like it.
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const addCleanup = (fn) => cp.cleanups.push(fn);
|
const addCleanup = (fn) => cp.cleanups.push(fn);
|
||||||
const runCleanups = () => {
|
const runCleanups = () => {
|
||||||
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||||
@@ -111,26 +142,11 @@ App.player = App.player || {};
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
// Title marquee (mirrors App.videos' card marquee math, scoped locally
|
// Title marquee. Only one title is on screen here, so unlike the grid it
|
||||||
// since the player title isn't a `.video-card`).
|
// always scrolls when it overflows -- there's nothing to pick between.
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
function measureTitle() {
|
function measureTitle() {
|
||||||
const wrap = q('.cp-title');
|
App.marquee.measure(q('.cp-title'), q('.cp-title-text'));
|
||||||
const text = q('.cp-title-text');
|
|
||||||
if (!wrap || !text) return;
|
|
||||||
const overflow = text.scrollWidth - wrap.clientWidth;
|
|
||||||
if (overflow > 4) {
|
|
||||||
const distance = overflow + 12;
|
|
||||||
const MARQUEE_SPEED = 28;
|
|
||||||
const MARQUEE_MIN_DURATION = 6;
|
|
||||||
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
|
|
||||||
text.style.setProperty('--marquee-distance', `${distance}px`);
|
|
||||||
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
|
||||||
wrap.classList.add('has-marquee');
|
|
||||||
} else {
|
|
||||||
wrap.classList.remove('has-marquee');
|
|
||||||
text.style.removeProperty('--marquee-distance');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
@@ -667,6 +683,10 @@ App.player = App.player || {};
|
|||||||
function playSources(videoData, opts) {
|
function playSources(videoData, opts) {
|
||||||
const video = cp.video;
|
const video = cp.video;
|
||||||
const token = ++cp.attemptToken;
|
const token = ++cp.attemptToken;
|
||||||
|
// Every route into here supersedes whatever was playing or loading: the
|
||||||
|
// direct route winning its race, a quality switch, a retry, a re-open.
|
||||||
|
// Void the old attempt's callbacks, then stop its requests.
|
||||||
|
cancelInFlight();
|
||||||
const resumeAt = (opts && opts.resumeAt) || 0;
|
const resumeAt = (opts && opts.resumeAt) || 0;
|
||||||
// Captured once per call rather than read from the shared `cp`
|
// Captured once per call rather than read from the shared `cp`
|
||||||
// object later: if open() is ever re-entered for a different video
|
// object later: if open() is ever re-entered for a different video
|
||||||
@@ -694,6 +714,39 @@ App.player = App.player || {};
|
|||||||
plan.push({ resolved, direct: false });
|
plan.push({ resolved, direct: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Whether a CDN will serve the browser directly is asked here, at play
|
||||||
|
// time, about this video's own media URL -- not in advance about the
|
||||||
|
// listing's. One provider can spread its media across several CDNs, so
|
||||||
|
// there is no single answer to pre-compute, and any answer taken from
|
||||||
|
// another video may not hold for this one.
|
||||||
|
//
|
||||||
|
// The question runs *alongside* the proxied playback rather than ahead
|
||||||
|
// of it, so it never delays anything: the proxy is already carrying the
|
||||||
|
// video while the direct route is being tested. If the answer comes
|
||||||
|
// back before any frame has been decoded, the attempt restarts on the
|
||||||
|
// direct URL -- nothing is on screen yet, so there is nothing to
|
||||||
|
// interrupt. If playback has already begun, the answer is kept, and the
|
||||||
|
// next video from that CDN starts direct without asking again.
|
||||||
|
const raceDirect = function(resolved) {
|
||||||
|
if (!App.videos || typeof App.videos.probeDirect !== 'function') return;
|
||||||
|
if (!resolved.url || resolved.isLive) return;
|
||||||
|
// An origin that demands a Referer can never be fetched directly by
|
||||||
|
// a browser, so there is nothing to find out.
|
||||||
|
if (resolved.refererRequired) return;
|
||||||
|
if (directProven(resolved.url)) return;
|
||||||
|
App.videos.probeDirect(resolved.url).then((ok) => {
|
||||||
|
if (!ok || token !== cp.attemptToken) return;
|
||||||
|
// readyState >= HAVE_CURRENT_DATA means a frame is up; leave a
|
||||||
|
// playing video alone rather than trading a visible stall for a
|
||||||
|
// saved hop.
|
||||||
|
if (!cp.video || cp.video.readyState >= 2) return;
|
||||||
|
// Direct won. Restarting cancels the proxy's fetch on the way
|
||||||
|
// in (see cancelInFlight), so the losing route stops pulling
|
||||||
|
// bytes instead of running to completion behind the winner.
|
||||||
|
playSources(videoData, Object.assign({}, opts, { resumeAt: resumeAt }));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const attempt = async (index) => {
|
const attempt = async (index) => {
|
||||||
if (token !== cp.attemptToken) return;
|
if (token !== cp.attemptToken) return;
|
||||||
const entry = plan[index];
|
const entry = plan[index];
|
||||||
@@ -734,31 +787,41 @@ App.player = App.player || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved);
|
let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved);
|
||||||
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
|
const kind = App.videos.classifySource(resolved);
|
||||||
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
|
let isHls = kind.isHls;
|
||||||
if (resolved.isLive) { isHls = true; isDirectMedia = false; }
|
let isDirectMedia = kind.isDirectMedia;
|
||||||
|
|
||||||
video.onerror = null;
|
cancelInFlight();
|
||||||
if (state.hlsPlayer) {
|
const attemptAbort = new AbortController();
|
||||||
state.hlsPlayer.stopLoad();
|
cp.fetchAbort = attemptAbort;
|
||||||
state.hlsPlayer.detachMedia();
|
|
||||||
state.hlsPlayer.destroy();
|
|
||||||
state.hlsPlayer = null;
|
|
||||||
}
|
|
||||||
video.pause();
|
|
||||||
video.removeAttribute('src');
|
|
||||||
video.load();
|
|
||||||
|
|
||||||
if (!isHls && !entry.direct) {
|
// Going out through the proxy: find out in parallel whether this
|
||||||
|
// CDN would have taken the browser directly.
|
||||||
|
if (!entry.direct) raceDirect(resolved);
|
||||||
|
|
||||||
|
// Last resort only: a HEAD through the proxy is a whole upstream
|
||||||
|
// connection (handshake included) before the first byte of video is
|
||||||
|
// ever requested, so it runs only when neither the URL nor the
|
||||||
|
// extractor's protocol says what this source is.
|
||||||
|
if (!isHls && !isDirectMedia && !entry.direct) {
|
||||||
try {
|
try {
|
||||||
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
const headResp = await fetch(streamUrl, {
|
||||||
if (token !== cp.attemptToken) return;
|
method: 'HEAD',
|
||||||
|
signal: attemptAbort.signal
|
||||||
|
});
|
||||||
const contentType = headResp.headers.get('Content-Type') || '';
|
const contentType = headResp.headers.get('Content-Type') || '';
|
||||||
if (contentType.includes('application/vnd.apple.mpegurl')) isHls = true;
|
if (contentType.includes('application/vnd.apple.mpegurl')) isHls = true;
|
||||||
else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) isDirectMedia = true;
|
else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) isDirectMedia = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort sniff only.
|
// Best-effort sniff only -- including the abort that
|
||||||
|
// cancelInFlight fires, which lands here rather than at the
|
||||||
|
// guard below.
|
||||||
}
|
}
|
||||||
|
// Outside the catch on purpose: an aborted sniff means this
|
||||||
|
// attempt has been superseded, and swallowing that with the
|
||||||
|
// failure of a best-effort sniff would let a dead attempt walk
|
||||||
|
// on and attach a stream to the player that replaced it.
|
||||||
|
if (token !== cp.attemptToken) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startPlayback = () => {
|
const startPlayback = () => {
|
||||||
@@ -856,14 +919,7 @@ App.player = App.player || {};
|
|||||||
const reopening = !!(cp.container && cp.container.classList.contains('open'));
|
const reopening = !!(cp.container && cp.container.classList.contains('open'));
|
||||||
if (reopening) {
|
if (reopening) {
|
||||||
cp.attemptToken++;
|
cp.attemptToken++;
|
||||||
if (state.hlsPlayer) {
|
cancelInFlight();
|
||||||
state.hlsPlayer.destroy();
|
|
||||||
state.hlsPlayer = null;
|
|
||||||
}
|
|
||||||
if (cp.video) {
|
|
||||||
cp.video.onerror = null;
|
|
||||||
cp.video.pause();
|
|
||||||
}
|
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
if (cp.originEl) cp.originEl.classList.remove('is-loading');
|
if (cp.originEl) cp.originEl.classList.remove('is-loading');
|
||||||
}
|
}
|
||||||
@@ -955,17 +1011,10 @@ App.player = App.player || {};
|
|||||||
App.player.close = function(opts) {
|
App.player.close = function(opts) {
|
||||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
if (!cp.container || !cp.container.classList.contains('open')) return;
|
||||||
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
||||||
|
// Closing the player must also stop what it was fetching -- otherwise a
|
||||||
if (state.hlsPlayer) {
|
// proxied stream keeps being pulled, and the server keeps an upstream
|
||||||
state.hlsPlayer.destroy();
|
// connection open, for a video nobody is watching any more.
|
||||||
state.hlsPlayer = null;
|
cancelInFlight();
|
||||||
}
|
|
||||||
if (cp.video) {
|
|
||||||
cp.video.onerror = null;
|
|
||||||
cp.video.pause();
|
|
||||||
cp.video.removeAttribute('src');
|
|
||||||
cp.video.load();
|
|
||||||
}
|
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
runCleanups();
|
runCleanups();
|
||||||
|
|
||||||
|
|||||||
BIN
frontend/js/sqlite.js
Normal file
BIN
frontend/js/sqlite.js
Normal file
Binary file not shown.
@@ -22,6 +22,7 @@ App.state = {
|
|||||||
App.constants = {
|
App.constants = {
|
||||||
FAVORITES_KEY: 'favorites',
|
FAVORITES_KEY: 'favorites',
|
||||||
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
FAVORITES_VISIBILITY_KEY: 'favoritesVisible',
|
||||||
|
FAVORITES_SORT_KEY: 'favoritesSort',
|
||||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -66,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');
|
||||||
};
|
};
|
||||||
@@ -616,7 +674,75 @@ 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.handleSearch = App.videos.handleSearch;
|
window.handleSearch = App.videos.handleSearch;
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateTitleActive = function(card) {
|
const updateTitleActive = function(card) {
|
||||||
if (!card || !card.classList.contains('has-marquee')) {
|
const titleWrap = card && card.querySelector('.video-title');
|
||||||
|
if (!titleWrap || !titleWrap.classList.contains('has-marquee')) {
|
||||||
if (card) card.classList.remove('is-title-active');
|
if (card) card.classList.remove('is-title-active');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -68,25 +69,13 @@ App.videos = App.videos || {};
|
|||||||
const titleWrap = card.querySelector('.video-title');
|
const titleWrap = card.querySelector('.video-title');
|
||||||
const titleText = card.querySelector('.video-title-text');
|
const titleText = card.querySelector('.video-title-text');
|
||||||
if (!titleWrap || !titleText) return;
|
if (!titleWrap || !titleText) return;
|
||||||
const overflow = titleText.scrollWidth - titleWrap.clientWidth;
|
if (App.marquee.measure(titleWrap, titleText)) {
|
||||||
if (overflow > 4) {
|
|
||||||
card.classList.add('has-marquee');
|
|
||||||
const distance = overflow + 12;
|
|
||||||
titleText.style.setProperty('--marquee-distance', `${distance}px`);
|
|
||||||
// Drive the duration off the distance so every title scrolls at the
|
|
||||||
// same gentle speed (px/sec) instead of a fixed duration that made
|
|
||||||
// longer titles whip past. A floor keeps short titles from snapping.
|
|
||||||
const MARQUEE_SPEED = 28; // px per second
|
|
||||||
const MARQUEE_MIN_DURATION = 6; // seconds
|
|
||||||
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
|
|
||||||
titleText.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
|
|
||||||
// Only marquee cards need the scroll-position observer that picks the
|
// Only marquee cards need the scroll-position observer that picks the
|
||||||
// centered card to animate; observing every card made scrolling a
|
// centered card to animate; observing every card made scrolling a
|
||||||
// large grid needlessly expensive.
|
// large grid needlessly expensive.
|
||||||
if (titleObserver) titleObserver.observe(card);
|
if (titleObserver) titleObserver.observe(card);
|
||||||
} else {
|
} else {
|
||||||
card.classList.remove('has-marquee', 'is-title-active');
|
card.classList.remove('is-title-active');
|
||||||
titleText.style.removeProperty('--marquee-distance');
|
|
||||||
if (titleObserver) {
|
if (titleObserver) {
|
||||||
titleObserver.unobserve(card);
|
titleObserver.unobserve(card);
|
||||||
titleVisibility.delete(card);
|
titleVisibility.delete(card);
|
||||||
@@ -134,27 +123,290 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
App.videos.attachNoReferrerRetry = function(img) {
|
// ---------------------------------------------------------------------
|
||||||
if (!img) return;
|
// Thumbnails
|
||||||
if (!img.dataset.originalSrc) {
|
//
|
||||||
img.dataset.originalSrc = img.currentSrc || img.src || '';
|
// A thumbnail can come from two places: the provider's own CDN, or our
|
||||||
|
// /api/image proxy. Neither is reliably the faster one -- the CDN is a hop
|
||||||
|
// closer, but plenty of them hotlink-block or rate-limit, and learning that
|
||||||
|
// used to cost a whole failed request before the proxy was even asked. That
|
||||||
|
// serial retry is the wait worth removing.
|
||||||
|
//
|
||||||
|
// So the first thumbnail from a host is raced: both requests go out at once
|
||||||
|
// and whichever answers first is the one displayed. The winner is then
|
||||||
|
// remembered per host -- hotlink and CORS policy are origin-level, the same
|
||||||
|
// assumption the direct-playability probe below makes -- so every later
|
||||||
|
// thumbnail from that host goes straight down the route that already
|
||||||
|
// worked. One race per host, not one per image: racing every card would
|
||||||
|
// double the image traffic of a whole grid to learn something we already
|
||||||
|
// know by the second card.
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
const IMAGE_DIRECT = 'direct';
|
||||||
|
const IMAGE_PROXY = 'proxy';
|
||||||
|
|
||||||
|
const imageRoutes = new Map(); // host -> winning route; absent = unknown
|
||||||
|
const imageRacing = new Set(); // hosts with a race already deciding
|
||||||
|
const imageWaiting = new Map(); // host -> images held until it decides
|
||||||
|
|
||||||
|
// A page of cards is built in one go, so every thumbnail from a host is
|
||||||
|
// attached before the first one has come back. Sending them all down the
|
||||||
|
// optimistic route is how the old serial retry hurt: on a host that blocks
|
||||||
|
// us, each card paid its own failed request before asking the proxy. So
|
||||||
|
// while a host is being decided the rest of its images wait for the answer
|
||||||
|
// -- at most as long as the fastest route takes -- and then load once, the
|
||||||
|
// right way round. If the race somehow stalls they go anyway.
|
||||||
|
const RACE_PATIENCE_MS = 2500;
|
||||||
|
|
||||||
|
// Direct is the route we'd rather settle on, so when the proxy comes home
|
||||||
|
// first the held images give direct this much longer to answer before
|
||||||
|
// committing to the proxy. Long enough that a provider merely a little
|
||||||
|
// slower than same-origin still wins its hosts; short enough that one which
|
||||||
|
// hangs -- the case this whole thing exists for -- doesn't hold up a grid.
|
||||||
|
const DIRECT_GRACE_MS = 200;
|
||||||
|
|
||||||
|
const imageHostOf = function(url) {
|
||||||
|
try {
|
||||||
|
return new URL(url, window.location.href).host;
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
img.dataset.noReferrerRetry = '0';
|
};
|
||||||
img.addEventListener('error', () => {
|
|
||||||
if (img.dataset.noReferrerRetry === '1') return;
|
// Where a thumbnail from this host should be fetched from. Answers with the
|
||||||
img.dataset.noReferrerRetry = '1';
|
// provider while the host is still unknown -- the optimistic route, and the
|
||||||
img.referrerPolicy = 'no-referrer';
|
// one a race starts on anyway.
|
||||||
img.removeAttribute('crossorigin');
|
App.videos.thumbnailUrl = function(url) {
|
||||||
const original = img.dataset.originalSrc || img.currentSrc || img.src || '';
|
if (!url) return '';
|
||||||
const proxyUrl = App.videos.buildImageProxyUrl(original);
|
return imageRoutes.get(imageHostOf(url)) === IMAGE_PROXY
|
||||||
if (proxyUrl) {
|
? (App.videos.buildImageProxyUrl(url) || url)
|
||||||
img.src = proxyUrl;
|
: url;
|
||||||
} else if (original) {
|
};
|
||||||
img.src = original;
|
|
||||||
|
// A src-less <img> counts as "unavailable", and the browser paints its alt
|
||||||
|
// text across the thumbnail box. Since a thumbnail now waits for its host's
|
||||||
|
// route before it gets a src, the caption is held back in a data attribute
|
||||||
|
// and put on only once there is an image to caption -- otherwise every card
|
||||||
|
// spells out its own title over the placeholder while the host is being
|
||||||
|
// decided, and permanently for an item that has no thumbnail at all.
|
||||||
|
const showThumbnail = function(img, url) {
|
||||||
|
if (img.dataset.alt !== undefined) {
|
||||||
|
img.alt = img.dataset.alt;
|
||||||
|
delete img.dataset.alt;
|
||||||
|
}
|
||||||
|
img.src = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Last resort on a route that normally works: one expired or missing image
|
||||||
|
// shouldn't be left broken just because its host is fine in general.
|
||||||
|
const attachProxyFallback = function(img, proxyUrl) {
|
||||||
|
if (!proxyUrl) return;
|
||||||
|
img.addEventListener('error', () => { showThumbnail(img, proxyUrl); }, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Releases the images held for `host`. `route` is the winner, or null when
|
||||||
|
// the race told us nothing (both routes failed, or it stalled) -- in which
|
||||||
|
// case they take the optimistic route with the proxy behind it, exactly as
|
||||||
|
// an undecided host used to.
|
||||||
|
const releaseWaiting = function(host, route) {
|
||||||
|
const waiting = imageWaiting.get(host);
|
||||||
|
if (!waiting) return;
|
||||||
|
imageWaiting.delete(host);
|
||||||
|
waiting.forEach((entry) => {
|
||||||
|
if (route === IMAGE_PROXY) {
|
||||||
|
showThumbnail(entry.img, entry.proxyUrl);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (route !== IMAGE_DIRECT) attachProxyFallback(entry.img, entry.proxyUrl);
|
||||||
|
showThumbnail(entry.img, entry.directUrl);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Two questions, and they don't have the same answer:
|
||||||
|
//
|
||||||
|
// which is quicker *now* -> what this image should display
|
||||||
|
// does direct work at all -> what the host is remembered as
|
||||||
|
//
|
||||||
|
// The proxy often wins the first question purely because it's same-origin:
|
||||||
|
// the browser already holds that connection, while the provider costs a
|
||||||
|
// fresh DNS lookup and TLS handshake. That says nothing about the provider,
|
||||||
|
// and pinning a host to the proxy over it would push every thumbnail on the
|
||||||
|
// page through our own server for no reason. So speed decides the pixels,
|
||||||
|
// and capability decides the memory: direct is remembered whenever it works
|
||||||
|
// at all, because it costs no server hop.
|
||||||
|
//
|
||||||
|
// Both routes are therefore fetched off-screen, and the visible image is
|
||||||
|
// pointed at the first one home. Racing on the visible element instead
|
||||||
|
// would abort the loser -- and the loser is the request that answers the
|
||||||
|
// second question.
|
||||||
|
const raceThumbnail = function(img, directUrl, proxyUrl, host) {
|
||||||
|
let shown = false;
|
||||||
|
let outstanding = 2;
|
||||||
|
let routeFinal = false; // the direct verdict is in; nothing can revise it
|
||||||
|
let abandoned = false; // took too long; a later race owns the host now
|
||||||
|
let directSettled = false;
|
||||||
|
let patience = null;
|
||||||
|
let grace = null;
|
||||||
|
|
||||||
|
const show = function(url) {
|
||||||
|
if (shown) return;
|
||||||
|
shown = true;
|
||||||
|
showThumbnail(img, url); // a cache hit; the probe has the bytes
|
||||||
|
};
|
||||||
|
|
||||||
|
// Records the host's route and lets go of everything held for it. The
|
||||||
|
// direct verdict is final; a route taken because direct was too slow to
|
||||||
|
// wait for is not, so a direct probe that comes home late still upgrades
|
||||||
|
// the host rather than leaving it on the proxy for the whole session.
|
||||||
|
//
|
||||||
|
// Crucially this happens the moment direct answers, not when both probes
|
||||||
|
// have finished: every thumbnail attached in the meantime is queued on
|
||||||
|
// exactly this answer, and making them wait on the *other* probe too
|
||||||
|
// leaves them blank for no reason.
|
||||||
|
const settleRoute = function(route, final) {
|
||||||
|
if (abandoned || routeFinal) return;
|
||||||
|
routeFinal = !!final;
|
||||||
|
if (final && patience) { clearTimeout(patience); patience = null; }
|
||||||
|
imageRoutes.set(host, route);
|
||||||
|
imageRacing.delete(host);
|
||||||
|
releaseWaiting(host, route);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nothing usable came home in time. Show whatever the host settled on --
|
||||||
|
// broken rather than blank, as it would have been without any of this --
|
||||||
|
// and keep the proxy behind an untested direct.
|
||||||
|
const giveUp = function() {
|
||||||
|
if (shown) return;
|
||||||
|
shown = true;
|
||||||
|
if (imageRoutes.get(host) === IMAGE_PROXY) {
|
||||||
|
showThumbnail(img, proxyUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
attachProxyFallback(img, proxyUrl);
|
||||||
|
showThumbnail(img, directUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decide = function() {
|
||||||
|
if (--outstanding > 0) return;
|
||||||
|
if (patience) { clearTimeout(patience); patience = null; }
|
||||||
|
if (grace) { clearTimeout(grace); grace = null; }
|
||||||
|
giveUp(); // no-op if either route came home
|
||||||
|
};
|
||||||
|
|
||||||
|
// Off-screen, and low priority: neither may take bandwidth from anything
|
||||||
|
// the reader is already looking at.
|
||||||
|
const newProbe = function() {
|
||||||
|
const image = new Image();
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.fetchPriority = 'low';
|
||||||
|
return image;
|
||||||
|
};
|
||||||
|
|
||||||
|
const directProbe = newProbe();
|
||||||
|
directProbe.referrerPolicy = 'no-referrer';
|
||||||
|
// The direct verdict alone decides the route -- either way round. One
|
||||||
|
// probe discovers the host's answer and every held image acts on it,
|
||||||
|
// instead of each rediscovering it at the cost of its own request.
|
||||||
|
directProbe.onload = function() {
|
||||||
|
directSettled = true;
|
||||||
|
if (grace) { clearTimeout(grace); grace = null; }
|
||||||
|
show(directUrl);
|
||||||
|
settleRoute(IMAGE_DIRECT, true);
|
||||||
|
decide();
|
||||||
|
};
|
||||||
|
directProbe.onerror = function() {
|
||||||
|
directSettled = true;
|
||||||
|
if (grace) { clearTimeout(grace); grace = null; }
|
||||||
|
// Direct is out for this host, so the proxy is the answer even if it
|
||||||
|
// hasn't reported yet -- there is nothing else left to be.
|
||||||
|
settleRoute(IMAGE_PROXY, true);
|
||||||
|
decide();
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyProbe = newProbe();
|
||||||
|
proxyProbe.onload = function() {
|
||||||
|
show(proxyUrl); // first one home gets the pixels on screen
|
||||||
|
// Don't strand anything behind a provider that may never answer:
|
||||||
|
// give it the grace window, then take the route that works. Marked
|
||||||
|
// provisional, so a slow-but-working provider still wins its host
|
||||||
|
// when it finally reports.
|
||||||
|
if (!directSettled && !grace) {
|
||||||
|
grace = setTimeout(function() {
|
||||||
|
grace = null;
|
||||||
|
settleRoute(IMAGE_PROXY, false);
|
||||||
|
}, DIRECT_GRACE_MS);
|
||||||
|
}
|
||||||
|
decide();
|
||||||
|
};
|
||||||
|
proxyProbe.onerror = function() { decide(); };
|
||||||
|
|
||||||
|
imageRacing.add(host);
|
||||||
|
// A probe that never answers -- a hung connection rather than a refused
|
||||||
|
// one -- must not leave the host mid-race forever, with every later
|
||||||
|
// thumbnail queueing behind a decision that will never come. Once that
|
||||||
|
// happens this race stops touching the shared maps entirely: the next
|
||||||
|
// thumbnail starts a fresh one, and a late answer here must not reach in
|
||||||
|
// and overwrite what *that* race decides.
|
||||||
|
patience = setTimeout(function() {
|
||||||
|
patience = null;
|
||||||
|
if (grace) { clearTimeout(grace); grace = null; }
|
||||||
|
if (!routeFinal) {
|
||||||
|
abandoned = true;
|
||||||
|
imageRacing.delete(host);
|
||||||
|
releaseWaiting(host, imageRoutes.get(host) || null);
|
||||||
|
}
|
||||||
|
giveUp();
|
||||||
|
}, RACE_PATIENCE_MS);
|
||||||
|
|
||||||
|
directProbe.src = directUrl;
|
||||||
|
proxyProbe.src = proxyUrl;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Points `img` at `url` by whichever route is known to work for its host,
|
||||||
|
// racing the two the first time that host is seen.
|
||||||
|
App.videos.attachThumbnail = function(img, url) {
|
||||||
|
const directUrl = url || (img && img.dataset.thumb) || '';
|
||||||
|
if (!img) return;
|
||||||
|
// Held back until there is an image to caption -- see showThumbnail. An
|
||||||
|
// item with no thumbnail keeps an empty alt: the card's own title sits
|
||||||
|
// directly beneath the box, so there is nothing for it to add.
|
||||||
|
if (img.alt && img.dataset.alt === undefined) {
|
||||||
|
img.dataset.alt = img.alt;
|
||||||
|
img.alt = '';
|
||||||
|
}
|
||||||
|
if (!directUrl) return;
|
||||||
|
// A cross-origin Referer is what most hotlink protection keys on, and an
|
||||||
|
// image needs none. Sending none is what lets the direct route work at
|
||||||
|
// all on a fair number of providers -- and the direct route is the one
|
||||||
|
// that costs us no server hop.
|
||||||
|
img.referrerPolicy = 'no-referrer';
|
||||||
|
|
||||||
|
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
||||||
|
const host = imageHostOf(directUrl);
|
||||||
|
const route = imageRoutes.get(host);
|
||||||
|
|
||||||
|
if (route === IMAGE_PROXY) {
|
||||||
|
showThumbnail(img, proxyUrl || directUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (imageRacing.has(host)) {
|
||||||
|
// A race is already deciding for this host. Wait for it rather than
|
||||||
|
// guessing: guessing wrong costs this image a whole failed request
|
||||||
|
// before it even asks the route that was about to be proven.
|
||||||
|
const waiting = imageWaiting.get(host) || [];
|
||||||
|
waiting.push({ img: img, directUrl: directUrl, proxyUrl: proxyUrl });
|
||||||
|
imageWaiting.set(host, waiting);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (route === IMAGE_DIRECT || !host || !proxyUrl) {
|
||||||
|
// Known good, or nothing to race against: take the provider and keep
|
||||||
|
// the proxy as this image's own fallback.
|
||||||
|
attachProxyFallback(img, proxyUrl);
|
||||||
|
showThumbnail(img, directUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
raceThumbnail(img, directUrl, proxyUrl, host);
|
||||||
|
};
|
||||||
|
|
||||||
// Each channel in a group sends back a different number of videos per
|
// Each channel in a group sends back a different number of videos per
|
||||||
// page, so a small per-channel count keeps any one channel from
|
// page, so a small per-channel count keeps any one channel from
|
||||||
// dominating a single interleaved batch.
|
// dominating a single interleaved batch.
|
||||||
@@ -290,7 +542,11 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const warmThumbnails = function(items) {
|
const warmThumbnails = function(items) {
|
||||||
const urls = (items || []).map((v) => v && v.thumb).filter(Boolean);
|
// Warm down the route the host has already settled on -- warming a URL
|
||||||
|
// the cards won't ask for would leave them waiting anyway.
|
||||||
|
const urls = (items || [])
|
||||||
|
.map((v) => v && App.videos.thumbnailUrl(v.thumb))
|
||||||
|
.filter(Boolean);
|
||||||
if (!urls.length) return Promise.resolve();
|
if (!urls.length) return Promise.resolve();
|
||||||
const loads = urls.map((url) => new Promise((resolve) => {
|
const loads = urls.map((url) => new Promise((resolve) => {
|
||||||
// Off-screen and not needed yet: low priority, started when the
|
// Off-screen and not needed yet: low priority, started when the
|
||||||
@@ -300,6 +556,12 @@ App.videos = App.videos || {};
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
img.fetchPriority = 'low';
|
img.fetchPriority = 'low';
|
||||||
|
// Same terms the card and the probe fetch on. Warming with a
|
||||||
|
// Referer the real request won't send would warm the wrong
|
||||||
|
// thing: on a hotlink-protecting host it earns a 403, which is
|
||||||
|
// both a wasted warm and a cached refusal the probe may then be
|
||||||
|
// handed -- pinning a host to the proxy that works direct.
|
||||||
|
img.referrerPolicy = 'no-referrer';
|
||||||
img.onload = resolve;
|
img.onload = resolve;
|
||||||
img.onerror = resolve; // the card's own retry handles failures
|
img.onerror = resolve; // the card's own retry handles failures
|
||||||
img.src = url;
|
img.src = url;
|
||||||
@@ -403,6 +665,12 @@ App.videos = App.videos || {};
|
|||||||
// button being pressed.
|
// button being pressed.
|
||||||
App.videos.loadVideos = async function(opts) {
|
App.videos.loadVideos = async function(opts) {
|
||||||
const force = !!(opts && opts.force);
|
const force = !!(opts && opts.force);
|
||||||
|
// The favorites grid pages out of localStorage, not the server, but
|
||||||
|
// rides the same sentinel and load-more button to get there.
|
||||||
|
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||||
|
App.favoritesView.loadNext();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const session = App.storage.getSession();
|
const session = App.storage.getSession();
|
||||||
if (!session || !session.channel) return;
|
if (!session || !session.channel) return;
|
||||||
if (loadRunning || state.isLoading) return;
|
if (loadRunning || state.isLoading) return;
|
||||||
@@ -445,7 +713,7 @@ App.videos = App.videos || {};
|
|||||||
// Builds a fully-wired video card element for `v`. Kept separate from
|
// Builds a fully-wired video card element for `v`. Kept separate from
|
||||||
// mounting so the virtualizer can create a card the moment it needs to be
|
// mounting so the virtualizer can create a card the moment it needs to be
|
||||||
// on screen and throw it away once it scrolls out of the window.
|
// on screen and throw it away once it scrolls out of the window.
|
||||||
App.videos.buildCard = function(v) {
|
App.videos.buildCard = function(v, options) {
|
||||||
const favoritesSet = App.favorites.getSet();
|
const favoritesSet = App.favorites.getSet();
|
||||||
const card = document.createElement('div');
|
const card = document.createElement('div');
|
||||||
card.className = 'video-card';
|
card.className = 'video-card';
|
||||||
@@ -460,14 +728,14 @@ App.videos = App.videos || {};
|
|||||||
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
const liveBadge = v.isLive ? '<span class="live-badge">● LIVE</span>' : '';
|
||||||
card.innerHTML = `
|
card.innerHTML = `
|
||||||
${liveBadge}
|
${liveBadge}
|
||||||
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}">♡</button>
|
<button class="favorite-btn" type="button" aria-pressed="false" aria-label="Add to favorites" data-fav-key="${favoriteKey || ''}" data-fav-url="${v.url || ''}">♡</button>
|
||||||
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
<button class="video-menu-btn" type="button" aria-haspopup="true" aria-expanded="false" aria-label="More options">⋯</button>
|
||||||
<div class="video-menu" role="menu">
|
<div class="video-menu" role="menu">
|
||||||
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
<button class="video-menu-item" type="button" data-action="info" role="menuitem">Show info</button>
|
||||||
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="video-thumb">
|
<div class="video-thumb">
|
||||||
<img src="${v.thumb}" alt="${v.title}" loading="lazy" decoding="async">
|
<img alt="${v.title}" loading="lazy" decoding="async">
|
||||||
<div class="video-loading" aria-hidden="true">
|
<div class="video-loading" aria-hidden="true">
|
||||||
<div class="video-loading-spinner"></div>
|
<div class="video-loading-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,7 +746,13 @@ App.videos = App.videos || {};
|
|||||||
${tagsMarkup}
|
${tagsMarkup}
|
||||||
`;
|
`;
|
||||||
const thumb = card.querySelector('img');
|
const thumb = card.querySelector('img');
|
||||||
App.videos.attachNoReferrerRetry(thumb);
|
// The layout probe (see shapeHeight) needs the card's shape, never its
|
||||||
|
// pixels: it measures against the CSS 16:9 placeholder and is removed in
|
||||||
|
// the same frame, so loading a thumbnail for it -- let alone racing one
|
||||||
|
// -- would be pure waste.
|
||||||
|
if (!(options && options.skipThumbnail)) {
|
||||||
|
App.videos.attachThumbnail(thumb, v.thumb);
|
||||||
|
}
|
||||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||||
if (favoriteBtn && favoriteKey) {
|
if (favoriteBtn && favoriteKey) {
|
||||||
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
|
App.favorites.setButtonState(favoriteBtn, favoritesSet.has(favoriteKey));
|
||||||
@@ -542,7 +816,7 @@ App.videos = App.videos || {};
|
|||||||
if (showInfoBtn) {
|
if (showInfoBtn) {
|
||||||
showInfoBtn.onclick = (event) => {
|
showInfoBtn.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.ui.showInfo(v);
|
App.ui.openInfo(v);
|
||||||
App.videos.closeAllMenus();
|
App.videos.closeAllMenus();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -559,7 +833,7 @@ App.videos = App.videos || {};
|
|||||||
App.player.open(v, { originEl: card });
|
App.player.open(v, { originEl: card });
|
||||||
};
|
};
|
||||||
cardVideo.set(card, v);
|
cardVideo.set(card, v);
|
||||||
card.addEventListener('pointerenter', () => App.videos.resolveAndProbe(v), { once: true });
|
card.addEventListener('pointerenter', () => App.videos.ensureFormats(v), { once: true });
|
||||||
return card;
|
return card;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -630,6 +904,17 @@ App.videos = App.videos || {};
|
|||||||
clearBtn.disabled = !hasValue;
|
clearBtn.disabled = !hasValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A search is a new result set, so it leaves the favorites grid.
|
||||||
|
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||||
|
App.favoritesView.close({ silent: true });
|
||||||
|
}
|
||||||
|
App.videos.resetGrid();
|
||||||
|
App.videos.loadVideos();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Empties the grid back to "nothing loaded yet", without deciding what
|
||||||
|
// fills it next -- the caller does that.
|
||||||
|
App.videos.resetGrid = function() {
|
||||||
// The held/in-flight page belongs to the old result set.
|
// The held/in-flight page belongs to the old result set.
|
||||||
App.videos.resetPrefetch();
|
App.videos.resetPrefetch();
|
||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
@@ -642,7 +927,6 @@ App.videos = App.videos || {};
|
|||||||
App.feed.reset();
|
App.feed.reset();
|
||||||
}
|
}
|
||||||
App.videos.updateLoadMoreState();
|
App.videos.updateLoadMoreState();
|
||||||
App.videos.loadVideos();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
App.videos.resetAndReload = function() {
|
App.videos.resetAndReload = function() {
|
||||||
@@ -651,18 +935,11 @@ App.videos = App.videos || {};
|
|||||||
state.currentLoadController = null;
|
state.currentLoadController = null;
|
||||||
state.isLoading = false;
|
state.isLoading = false;
|
||||||
}
|
}
|
||||||
// The held/in-flight page belongs to the old result set.
|
// Switching source/channel/filters means leaving the favorites grid.
|
||||||
App.videos.resetPrefetch();
|
if (App.favoritesView && App.favoritesView.isActive()) {
|
||||||
state.currentPage = 1;
|
App.favoritesView.close({ silent: true });
|
||||||
state.hasNextPage = true;
|
|
||||||
state.renderedVideoIds.clear();
|
|
||||||
state.loadedVideos = [];
|
|
||||||
state.groupCursors = null;
|
|
||||||
App.virtualGrid.reset();
|
|
||||||
if (App.feed && typeof App.feed.reset === 'function') {
|
|
||||||
App.feed.reset();
|
|
||||||
}
|
}
|
||||||
App.videos.updateLoadMoreState();
|
App.videos.resetGrid();
|
||||||
App.videos.loadVideos();
|
App.videos.loadVideos();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -757,7 +1034,7 @@ App.videos = App.videos || {};
|
|||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
const el = grid();
|
const el = grid();
|
||||||
if (!el) return 240;
|
if (!el) return 240;
|
||||||
const probe = App.videos.buildCard(v);
|
const probe = App.videos.buildCard(v, { skipThumbnail: true });
|
||||||
probe.style.position = 'absolute';
|
probe.style.position = 'absolute';
|
||||||
probe.style.visibility = 'hidden';
|
probe.style.visibility = 'hidden';
|
||||||
probe.style.left = '-99999px';
|
probe.style.left = '-99999px';
|
||||||
@@ -888,13 +1165,13 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
// Marquee + direct-playability probe only matter for on-screen cards.
|
// Marquee + direct-playability probe only matter for on-screen cards.
|
||||||
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
|
requestAnimationFrame(() => { if (mounted.get(i) === card) measureTitle(card); });
|
||||||
probeObserver.observe(card);
|
resolveObserver.observe(card);
|
||||||
};
|
};
|
||||||
|
|
||||||
const unmount = function(i) {
|
const unmount = function(i) {
|
||||||
const card = mounted.get(i);
|
const card = mounted.get(i);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
probeObserver.unobserve(card);
|
resolveObserver.unobserve(card);
|
||||||
if (titleObserver) {
|
if (titleObserver) {
|
||||||
titleObserver.unobserve(card);
|
titleObserver.unobserve(card);
|
||||||
titleVisibility.delete(card);
|
titleVisibility.delete(card);
|
||||||
@@ -1274,7 +1551,15 @@ App.videos = App.videos || {};
|
|||||||
const referer = explicitReferer || deriveReferer(fmt.url);
|
const referer = explicitReferer || deriveReferer(fmt.url);
|
||||||
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
||||||
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
||||||
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
|
// `protocol` is the extractor's own word for how this format is
|
||||||
|
// delivered ('https', 'm3u8_native', ...). Carrying it through lets
|
||||||
|
// the player skip its content-type sniff -- a full round trip
|
||||||
|
// through the proxy -- for URLs whose extension gives nothing away.
|
||||||
|
return {
|
||||||
|
url: fmt.url, referer, userAgent, headers, isLive,
|
||||||
|
refererRequired: !!explicitReferer,
|
||||||
|
protocol: fmt.protocol || ''
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!sources.length) {
|
if (!sources.length) {
|
||||||
@@ -1312,7 +1597,37 @@ App.videos = App.videos || {};
|
|||||||
const referer = explicitReferer || deriveReferer(fmt.url);
|
const referer = explicitReferer || deriveReferer(fmt.url);
|
||||||
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
const userAgent = headerValue(fmt.http_headers, 'User-Agent') || metaUserAgent;
|
||||||
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
const headers = mergeHeaders(meta.http_headers, fmt.http_headers);
|
||||||
return { url: fmt.url, referer, userAgent, headers, isLive, refererRequired: !!explicitReferer };
|
return {
|
||||||
|
url: fmt.url, referer, userAgent, headers, isLive,
|
||||||
|
refererRequired: !!explicitReferer,
|
||||||
|
protocol: fmt.protocol || ''
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// How does this source play -- an HLS manifest, or a media file the <video>
|
||||||
|
// element can take directly? Answered from the strongest evidence at hand:
|
||||||
|
// a live stream is always a manifest here, then the extractor's `protocol`,
|
||||||
|
// then the URL's extension. When none of them says (a signed CDN link with
|
||||||
|
// no extension and no protocol), the caller is left to sniff the content
|
||||||
|
// type over the network, which is why `protocol` is worth carrying around.
|
||||||
|
App.videos.classifySource = function(resolved) {
|
||||||
|
const url = (resolved && resolved.url) || '';
|
||||||
|
let isHls = /\.m3u8($|\?)/i.test(url);
|
||||||
|
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(url);
|
||||||
|
const protocol = String((resolved && resolved.protocol) || '').toLowerCase();
|
||||||
|
if (protocol.indexOf('m3u8') >= 0) {
|
||||||
|
isHls = true;
|
||||||
|
isDirectMedia = false;
|
||||||
|
} else if (protocol === 'https' || protocol === 'http') {
|
||||||
|
// A plain HTTP(S) download: one file, played as-is. Anything else
|
||||||
|
// yt-dlp names (http_dash_segments, ism, ...) stays unknown here.
|
||||||
|
isDirectMedia = true;
|
||||||
|
}
|
||||||
|
if (resolved && resolved.isLive) {
|
||||||
|
isHls = true;
|
||||||
|
isDirectMedia = false;
|
||||||
|
}
|
||||||
|
return { isHls, isDirectMedia };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Background "direct playability" probe. The backend proxy exists to work
|
// Background "direct playability" probe. The backend proxy exists to work
|
||||||
@@ -1354,22 +1669,42 @@ App.videos = App.videos || {};
|
|||||||
let ok = false;
|
let ok = false;
|
||||||
let detail = '';
|
let detail = '';
|
||||||
try {
|
try {
|
||||||
// A simple GET (no custom headers) avoids a CORS preflight. If
|
// A simple GET (no custom headers) avoids a CORS preflight.
|
||||||
// the response is readable and successful, CORS + reachability
|
|
||||||
// are both proven; we abort immediately so the body isn't
|
|
||||||
// downloaded (it can be a whole video file).
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
mode: 'cors',
|
mode: 'cors',
|
||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
ok = res.ok || res.status === 206;
|
if (!(res.ok || res.status === 206)) {
|
||||||
detail = `HTTP ${res.status}`;
|
ok = false;
|
||||||
|
detail = `HTTP ${res.status}`;
|
||||||
|
} else if (res.body && typeof res.body.getReader === 'function') {
|
||||||
|
// Run it until actual media bytes arrive. A readable status
|
||||||
|
// line is weaker evidence than it looks: the question is
|
||||||
|
// whether this origin will hand *the player* video data
|
||||||
|
// cross-origin, and that isn't settled until some has
|
||||||
|
// arrived. Then stop -- the rest of the file is not our
|
||||||
|
// business, and it can be a whole film.
|
||||||
|
const reader = res.body.getReader();
|
||||||
|
const chunk = await reader.read();
|
||||||
|
const bytes = (!chunk.done && chunk.value && chunk.value.length) || 0;
|
||||||
|
ok = bytes > 0;
|
||||||
|
detail = `HTTP ${res.status}, ${bytes} bytes`;
|
||||||
|
reader.cancel().catch(() => {});
|
||||||
|
} else {
|
||||||
|
// No readable stream to sample (an old browser): the status
|
||||||
|
// line is all the evidence on offer.
|
||||||
|
ok = true;
|
||||||
|
detail = `HTTP ${res.status}, headers only`;
|
||||||
|
}
|
||||||
controller.abort();
|
controller.abort();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// A CORS refusal lands here as a TypeError with no status --
|
||||||
|
// the browser won't say more than "failed" about a response it
|
||||||
|
// wouldn't let us read.
|
||||||
ok = false;
|
ok = false;
|
||||||
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'fetch failed';
|
detail = (err && err.name === 'AbortError') ? 'timeout' : (err && err.message) || 'blocked (CORS)';
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
@@ -1382,37 +1717,18 @@ App.videos = App.videos || {};
|
|||||||
return promise;
|
return promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Kicks off a background probe of a video's best (first-played) source so a
|
|
||||||
// later playback can skip the proxy if its host is proven reachable. Only
|
|
||||||
// runs once the video has resolved formats (see resolveAndProbe): those are
|
|
||||||
// real media URLs (or redirects to them), whereas a bare listing item only
|
|
||||||
// carries a page URL that the player can't use directly.
|
|
||||||
App.videos.probeVideoSources = function(video) {
|
|
||||||
if (!video || typeof video !== 'object') return;
|
|
||||||
const meta = video.meta || video;
|
|
||||||
if (!meta || !Array.isArray(meta.formats) || !meta.formats.length) return;
|
|
||||||
let sources;
|
|
||||||
try {
|
|
||||||
sources = App.videos.resolveStreamSources(video);
|
|
||||||
} catch (err) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const best = sources && sources[0];
|
|
||||||
if (!best || !best.url || best.isLive) return;
|
|
||||||
// Sources that require a specific upstream Referer can't be fetched
|
|
||||||
// directly by the browser (it can't forge a cross-origin Referer), so a
|
|
||||||
// probe would always fail -- leave them to the proxy.
|
|
||||||
if (best.refererRequired) return;
|
|
||||||
App.videos.probeDirect(best.url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Listing items arrive without formats (meta is null) -- only a page URL --
|
// Listing items arrive without formats (meta is null) -- only a page URL --
|
||||||
// so there's nothing direct-playable to probe up front. This resolves a
|
// so this resolves a video's real media formats via the backend (yt-dlp) and
|
||||||
// video's real media formats via the backend (yt-dlp), attaches them as
|
// attaches them as `video.meta`, which is what playback, the quality menu
|
||||||
// `video.meta` so the player and probe can use them, then probes the best
|
// and the hover preview all need. Resolution is per-video and deduped: it
|
||||||
// source. Resolution is per-video and deduped: it runs at most once per
|
// runs at most once per video, triggered lazily by hover/scroll so we don't
|
||||||
// video, triggered lazily by hover/scroll so we don't resolve cards the
|
// resolve cards the user never looks at.
|
||||||
// user never looks at.
|
//
|
||||||
|
// It deliberately does *not* test direct playability. That question belongs
|
||||||
|
// to the video actually being played (see raceDirect in player.js): a
|
||||||
|
// provider can spread its media over several CDNs, so an answer taken from
|
||||||
|
// whichever card happened to scroll past need not hold for the one the
|
||||||
|
// reader picks.
|
||||||
const cardVideo = new WeakMap();
|
const cardVideo = new WeakMap();
|
||||||
|
|
||||||
// Session cache of `/api/resolve` results, keyed by video id (falling back
|
// Session cache of `/api/resolve` results, keyed by video id (falling back
|
||||||
@@ -1483,21 +1799,56 @@ App.videos = App.videos || {};
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const probeObserver = new IntersectionObserver((entries) => {
|
// Everything the extractor knows about a video, for the Show info panel:
|
||||||
|
// description, dates, counts, categories, thumbnails, every field of every
|
||||||
|
// format. Deliberately not `ensureFormats`' payload and deliberately not
|
||||||
|
// attached to `video.meta` -- meta is fetched for every card that scrolls
|
||||||
|
// past and is kept small on purpose, and it names things playback's way
|
||||||
|
// (`isLive`) rather than the extractor's (`is_live`). Cached per video for
|
||||||
|
// the session, like meta, so reopening the panel is free.
|
||||||
|
const fullInfoCache = new Map();
|
||||||
|
|
||||||
|
App.videos.fetchFullInfo = function(video) {
|
||||||
|
if (!video || typeof video !== 'object' || !video.url) return Promise.resolve(null);
|
||||||
|
// Same reasoning as ensureFormats: a URL that already names a media file
|
||||||
|
// has nothing to extract, and re-fetching a signed one can burn a
|
||||||
|
// single-use link that playback still needs.
|
||||||
|
if (isDirectMediaUrl(video.url)) return Promise.resolve(null);
|
||||||
|
|
||||||
|
const cacheKey = video.id || video.url;
|
||||||
|
let promise = fullInfoCache.get(cacheKey);
|
||||||
|
if (!promise) {
|
||||||
|
promise = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/resolve', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: video.url, full: '1' })
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data || typeof data !== 'object' || !Object.keys(data).length) return null;
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort: the panel still shows what the client holds.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
fullInfoCache.set(cacheKey, promise);
|
||||||
|
}
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveObserver = new IntersectionObserver((entries) => {
|
||||||
entries.forEach((entry) => {
|
entries.forEach((entry) => {
|
||||||
if (!entry.isIntersecting) return;
|
if (!entry.isIntersecting) return;
|
||||||
probeObserver.unobserve(entry.target);
|
resolveObserver.unobserve(entry.target);
|
||||||
const video = cardVideo.get(entry.target);
|
const video = cardVideo.get(entry.target);
|
||||||
if (video) App.videos.resolveAndProbe(video);
|
if (video) App.videos.ensureFormats(video);
|
||||||
});
|
});
|
||||||
}, { rootMargin: '200px' });
|
}, { rootMargin: '200px' });
|
||||||
|
|
||||||
App.videos.resolveAndProbe = function(video) {
|
|
||||||
if (!video || typeof video !== 'object') return Promise.resolve();
|
|
||||||
return App.videos.ensureFormats(video).then((meta) => {
|
|
||||||
if (meta) App.videos.probeVideoSources(video);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
// Builds a proxied stream URL. Extra params other than `url` are forwarded
|
||||||
// by the backend as request headers, so use real header names here.
|
// by the backend as request headers, so use real header names here.
|
||||||
|
|||||||
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