Compare commits
16 Commits
0009574b77
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a795442634 | ||
|
|
b478c51551 | ||
|
|
191c81e55e | ||
|
|
2abdc31f56 | ||
|
|
76a078b9b5 | ||
|
|
451bf0f983 | ||
|
|
764e3416a3 | ||
|
|
f0df53365d | ||
|
|
6e5ab68a94 | ||
|
|
e603111d70 | ||
|
|
7624ca559a | ||
|
|
49992c1db0 | ||
|
|
b4031b5d0e | ||
|
|
1dbac33359 | ||
|
|
74b719b2ea | ||
|
|
e2632c962d |
@@ -134,10 +134,12 @@ def impersonate_get(url, **kwargs):
|
||||
_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
|
||||
# `live` is purely a playback hint and must not leak upstream as a header.
|
||||
STREAM_RESERVED_PARAMS = {'url', 'live'}
|
||||
# `live` is purely a playback hint and must not leak upstream as a header. `full`
|
||||
# is /api/resolve's "give me everything" switch and is likewise ours, not the
|
||||
# origin's.
|
||||
STREAM_RESERVED_PARAMS = {'url', 'live', 'full'}
|
||||
# Headers that affect the transport layer rather than the resource itself; allowing
|
||||
# these to be forwarded could enable request smuggling or vhost-routing abuse.
|
||||
STREAM_DISALLOWED_HEADER_NAMES = {'host', 'content-length', 'transfer-encoding', 'connection', 'expect'}
|
||||
@@ -297,6 +299,24 @@ _RESOLVE_FORMAT_FIELDS = ('url', 'http_headers', 'height', 'width', 'tbr',
|
||||
'fps', 'vcodec', 'acodec', 'ext', 'video_ext', 'quality',
|
||||
'protocol', 'format_note')
|
||||
|
||||
|
||||
def _trim_resolve_info(info):
|
||||
"""The lean payload playback needs: the media URLs, the headers that make
|
||||
them work, and just enough per-format detail to rank them. This is what
|
||||
every hovered card asks for, so it stays small."""
|
||||
formats = []
|
||||
for fmt in ((info.get('formats') if info else None) or []):
|
||||
if not fmt.get('url'):
|
||||
continue
|
||||
formats.append({k: fmt.get(k) for k in _RESOLVE_FORMAT_FIELDS if fmt.get(k) is not None})
|
||||
|
||||
return {
|
||||
'url': info.get('url') if info else None,
|
||||
'http_headers': (info.get('http_headers') if info else None) or {},
|
||||
'isLive': bool(info.get('is_live')) if info else False,
|
||||
'formats': formats,
|
||||
}
|
||||
|
||||
# Some channels surface pages that yt-dlp can't extract because the video is
|
||||
# embedded in a third-party JS player iframe (e.g. the xtremestream family used
|
||||
# by tube.perverzija.com). The player page declares its HLS playlist URL as
|
||||
@@ -354,7 +374,13 @@ def resolve_video():
|
||||
"""Resolve a page URL to its playable formats via yt-dlp and return them as
|
||||
JSON. The frontend calls this on demand (when a card is hovered or scrolled
|
||||
into view) to learn the real media URLs so it can background-probe them for
|
||||
direct, proxy-free playability."""
|
||||
direct, proxy-free playability.
|
||||
|
||||
`full=1` returns the extractor's whole info dict instead of the trimmed
|
||||
playback payload -- everything it knows about the video (description, dates,
|
||||
counts, tags, thumbnails, every format field), which is what the Show info
|
||||
panel exists to display. Both views come from one extraction and one cache
|
||||
entry, so asking for the full one costs no extra work upstream."""
|
||||
if request.method == 'POST':
|
||||
source = request.json or {}
|
||||
video_url = source.get('url')
|
||||
@@ -365,11 +391,19 @@ def resolve_video():
|
||||
if not video_url:
|
||||
return jsonify({"error": "No URL provided"}), 400
|
||||
|
||||
want_full = str(source.get('full', '')).strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
def view_of(info):
|
||||
if not want_full:
|
||||
return _trim_resolve_info(info)
|
||||
# Nothing to show, but answer in the same shape rather than `null`.
|
||||
return info if info else {}
|
||||
|
||||
now = time.time()
|
||||
with _resolve_cache_lock:
|
||||
cached = _resolve_cache.get(video_url)
|
||||
if cached and cached[0] > now:
|
||||
return jsonify(cached[1])
|
||||
return jsonify(view_of(cached[1]))
|
||||
|
||||
ydl_opts = {
|
||||
'quiet': True,
|
||||
@@ -386,6 +420,10 @@ def resolve_video():
|
||||
try:
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(video_url, download=False)
|
||||
# The raw info dict holds objects that don't survive JSON (and
|
||||
# internal `__`-prefixed bookkeeping). This is the same pass yt-dlp
|
||||
# itself runs behind --dump-json.
|
||||
info = ydl.sanitize_info(info, remove_private_keys=True)
|
||||
except Exception as e:
|
||||
# Many channels point at sites yt-dlp can't extract ("Unsupported URL").
|
||||
# That's not fatal here -- the embed fallback below may still find a
|
||||
@@ -400,26 +438,17 @@ def resolve_video():
|
||||
if embed:
|
||||
info = embed
|
||||
|
||||
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})
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
# The extraction is cached whole, and each caller is served the view it
|
||||
# asked for. A failed extraction (info is None) is cached the same way, so a
|
||||
# video that can't be resolved is attempted once per TTL rather than on
|
||||
# every hover.
|
||||
with _resolve_cache_lock:
|
||||
# Drop expired entries so the cache doesn't grow without bound.
|
||||
for key in [k for k, v in _resolve_cache.items() if v[0] <= now]:
|
||||
_resolve_cache.pop(key, None)
|
||||
_resolve_cache[video_url] = (now + RESOLVE_CACHE_TTL, 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'])
|
||||
def image_proxy():
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
/* A card keeps its optional parts (live badge, uploader, duration, tags) at all
|
||||
times and hides the ones this video doesn't need, so any pooled card fits any
|
||||
video -- see bindCard. Several of those carry their own `display`, which beats
|
||||
the UA rule for [hidden], so say it once and mean it. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
:root {
|
||||
/* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */
|
||||
--bg-primary: #14110d;
|
||||
@@ -857,6 +863,8 @@ body.favorites-view-open .favorites-empty {
|
||||
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 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
@@ -864,6 +872,20 @@ body.favorites-view-open .favorites-empty {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-display);
|
||||
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 {
|
||||
@@ -1025,6 +1047,12 @@ body.favorites-view-open .favorites-empty {
|
||||
precomputed (top,left). */
|
||||
}
|
||||
|
||||
/* Each card's layout and paint stay its own business, so inserting one during
|
||||
a scroll cannot make the browser reconsider the rest of the grid. */
|
||||
.video-card {
|
||||
contain: layout paint;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 16px 28px var(--shadow);
|
||||
@@ -1296,7 +1324,7 @@ body.theme-light .video-menu-btn {
|
||||
}
|
||||
|
||||
/* Live streams can't be seeked, so hide the feed scrubber. */
|
||||
.feed-slide.is-live .feed-timeline {
|
||||
.feed-pane.is-live .feed-timeline {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1387,6 +1415,31 @@ body.theme-light .favorite-btn {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -2105,6 +2158,9 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* One screenful. It holds the pane tree rather than a video directly: with
|
||||
several panes, a step shows that many videos at once and one swipe advances
|
||||
the whole set. */
|
||||
.feed-slide {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -2112,6 +2168,34 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
height: 100dvh;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
/* A branch of the tree: two children sharing the space, side by side or
|
||||
stacked. Nesting these is what makes any arrangement reachable. */
|
||||
.feed-split {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.feed-split.is-row { flex-direction: row; }
|
||||
.feed-split.is-col { flex-direction: column; }
|
||||
|
||||
/* min-* is what stops a flex child refusing to shrink below its content. */
|
||||
.feed-split > .feed-pane,
|
||||
.feed-split > .feed-split {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.feed-pane {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -2119,6 +2203,45 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.feed-pane-tools {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
/* Above the video, below the right-hand controls it sits beside. */
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.feed-pane-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(244, 232, 212, 0.18);
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
backdrop-filter: blur(6px);
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.feed-pane-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.feed-pane-mute.is-muted {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
/* The HUD's idle fade takes the panel tools with it, like every other control. */
|
||||
.feed-hud-idle .feed-pane-tools {
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.feed-poster,
|
||||
.feed-video {
|
||||
position: absolute;
|
||||
@@ -2132,7 +2255,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.feed-slide.is-loaded .feed-poster {
|
||||
.feed-pane.is-loaded .feed-poster {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2162,7 +2285,7 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
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;
|
||||
will-change: transform;
|
||||
}
|
||||
@@ -2257,6 +2380,22 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
}
|
||||
|
||||
/* Per-slide favorite (heart) button on the feed HUD right rail. */
|
||||
/* The right-hand controls stack upwards from just above the caption, rather
|
||||
than sitting at fixed distances from the bottom. A pane can be a third of
|
||||
the viewport tall, and viewport-scale offsets put these outside it -- clipped
|
||||
by the pane's own overflow and unreachable. */
|
||||
.feed-pane .feed-fav-btn,
|
||||
.feed-pane .feed-pip-btn,
|
||||
.feed-pane .feed-format-btn {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: auto;
|
||||
top: 56px;
|
||||
}
|
||||
|
||||
.feed-pane .feed-pip-btn { top: 100px; }
|
||||
.feed-pane .feed-format-btn { top: 144px; }
|
||||
|
||||
.feed-fav-btn {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
@@ -2325,13 +2464,13 @@ body.feed-mode-open .mode-toggle-btn .icon-svg {
|
||||
/* Reels/TikTok mode: HUD auto-hides after a short idle. Elements stay
|
||||
interactive (pointer-events untouched) so the buttons keep working while
|
||||
invisible; any pointer/scroll activity reveals them again (see App.feed). */
|
||||
body.feed-hud-idle .feed-info,
|
||||
body.feed-hud-idle .feed-timeline,
|
||||
body.feed-hud-idle .feed-mute-btn,
|
||||
body.feed-hud-idle .feed-fav-btn,
|
||||
body.feed-hud-idle .feed-pip-btn,
|
||||
body.feed-hud-idle .feed-format-btn,
|
||||
body.feed-hud-idle .mode-toggle-btn {
|
||||
.feed-hud-idle .feed-info,
|
||||
.feed-hud-idle .feed-timeline,
|
||||
.feed-hud-idle .feed-mute-btn,
|
||||
.feed-hud-idle .feed-fav-btn,
|
||||
.feed-hud-idle .feed-pip-btn,
|
||||
.feed-hud-idle .feed-format-btn,
|
||||
.feed-hud-idle .mode-toggle-btn {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -2565,6 +2704,257 @@ body.theme-light .video-card img:not(.is-loaded) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Channel picker ---------------------------------------------------- */
|
||||
/* The trigger in the menu drawer: what's being read now, favicon and all. */
|
||||
.channel-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.channel-trigger:hover,
|
||||
.channel-trigger:focus-visible {
|
||||
border-color: var(--border-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.channel-trigger-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-trigger-name,
|
||||
.channel-trigger-note {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channel-trigger-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.channel-trigger-caret {
|
||||
flex-shrink: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--text-secondary);
|
||||
}
|
||||
|
||||
/* A favicon, with the channel's initial behind it for the ones that never
|
||||
arrive -- a row is never a name beside an empty square. */
|
||||
.channel-mark {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
background: rgba(201, 165, 103, 0.14);
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.channel-mark::before {
|
||||
content: attr(data-letter);
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-favicon {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
padding: 3px;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.channel-picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4200;
|
||||
display: none;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 8vh 12px 12px 12px;
|
||||
background: rgba(10, 8, 4, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.channel-picker.open { display: flex; }
|
||||
|
||||
.channel-picker-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(560px, 100%);
|
||||
max-height: min(74vh, 700px);
|
||||
/* Solid, not the usual translucent panel: the section headings stick over
|
||||
the rows as they scroll, and a see-through heading is unreadable. */
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 30px 70px var(--shadow);
|
||||
overflow: hidden;
|
||||
animation: card-rise 0.2s ease both;
|
||||
}
|
||||
|
||||
.channel-picker-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px 10px 18px;
|
||||
}
|
||||
|
||||
.channel-picker-head h3 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.channel-search {
|
||||
margin: 0 18px 12px 18px;
|
||||
padding: 11px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-search::placeholder { color: var(--text-secondary); }
|
||||
.channel-search:focus { outline: none; border-color: var(--border-hover); }
|
||||
|
||||
.channel-picker-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px 10px;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.channel-section {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
padding: 10px 8px 6px 8px;
|
||||
background: var(--bg-primary);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-body);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.channel-row.is-active { background: var(--bg-tertiary); }
|
||||
|
||||
.channel-row.is-current {
|
||||
border-color: var(--border-hover);
|
||||
background: rgba(201, 165, 103, 0.10);
|
||||
}
|
||||
|
||||
.channel-row-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.channel-row-name {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.channel-row-note {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.channel-tag {
|
||||
padding: 2px 7px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.channel-row-flag {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.channel-picker-empty {
|
||||
padding: 8px 20px 20px 20px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.channel-picker { padding: 0; }
|
||||
|
||||
.channel-picker-box {
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */
|
||||
.feed-title { font-family: var(--font-display); }
|
||||
|
||||
|
||||
@@ -58,8 +58,17 @@
|
||||
<select id="source-select"></select>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<label for="channel-select">Channel</label>
|
||||
<select id="channel-select"></select>
|
||||
<label for="channel-picker-btn">Channel</label>
|
||||
<button id="channel-picker-btn" class="channel-trigger" type="button" aria-haspopup="dialog" aria-expanded="false">
|
||||
<span class="channel-mark" id="channel-trigger-mark">
|
||||
<img class="channel-favicon" id="channel-trigger-icon" alt="" decoding="async">
|
||||
</span>
|
||||
<span class="channel-trigger-text">
|
||||
<span class="channel-trigger-name" id="channel-trigger-name">No channel</span>
|
||||
<span class="channel-trigger-note" id="channel-trigger-note"></span>
|
||||
</span>
|
||||
<span class="channel-trigger-caret" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -194,6 +203,19 @@
|
||||
|
||||
<button id="back-to-top" class="back-to-top" type="button" title="Back to top" aria-label="Back to top">↑</button>
|
||||
|
||||
<div id="channel-picker" class="channel-picker" aria-hidden="true">
|
||||
<div class="channel-picker-box" role="dialog" aria-modal="true" aria-labelledby="channel-picker-title">
|
||||
<div class="channel-picker-head">
|
||||
<h3 id="channel-picker-title">Channel</h3>
|
||||
<button id="channel-picker-close" class="close-btn" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<input id="channel-search" class="channel-search" type="text" placeholder="Search channels…"
|
||||
autocomplete="off" spellcheck="false" aria-controls="channel-picker-list">
|
||||
<div id="channel-picker-list" class="channel-picker-list" role="listbox" aria-label="Channels"></div>
|
||||
<div id="channel-picker-empty" class="channel-picker-empty">No channel matches that.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="command-palette" class="command-palette" aria-hidden="true">
|
||||
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
||||
<input id="cmdk-input" class="cmdk-input" type="text" placeholder="Type a command or search… (⌘K)" autocomplete="off" spellcheck="false">
|
||||
@@ -202,6 +224,7 @@
|
||||
</div>
|
||||
|
||||
<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/customPlayer.js"></script>
|
||||
<script src="static/js/player.js"></script>
|
||||
|
||||
@@ -194,36 +194,138 @@ App.customPlayer = App.customPlayer || {};
|
||||
// tab/app is backgrounded while a video is playing (Safari does this
|
||||
// natively for inline video; Chrome/Android need an explicit call).
|
||||
// -----------------------------------------------------------------
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return !!(document.pictureInPictureEnabled);
|
||||
// iOS has picture-in-picture, but not this API: Safari on iPhone and iPad
|
||||
// never implemented requestPictureInPicture, and exposes WebKit's older
|
||||
// presentation-mode switch instead. document.pictureInPictureEnabled is
|
||||
// undefined there, so every check below it used to answer "no" and the
|
||||
// button was hidden on the one platform where people most want it.
|
||||
//
|
||||
// The difference is confined here. enterPiP/exitPiP/pipElement speak for
|
||||
// both, and bindPiPEvents re-fires WebKit's non-bubbling
|
||||
// webkitpresentationmodechanged as the standard enter/leave events, so the
|
||||
// delegated listeners elsewhere work unchanged.
|
||||
const WEBKIT_PIP = 'picture-in-picture';
|
||||
let webkitPipElement = null;
|
||||
|
||||
const standardPiP = () => !!document.pictureInPictureEnabled;
|
||||
const webkitPiP = (video) => !!(video && typeof video.webkitSetPresentationMode === 'function');
|
||||
|
||||
// No element to ask about: does this browser have either API at all?
|
||||
let webkitProbe = null;
|
||||
const webkitAvailable = function() {
|
||||
if (webkitProbe === null) {
|
||||
webkitProbe = typeof HTMLVideoElement !== 'undefined' &&
|
||||
(typeof HTMLVideoElement.prototype.webkitSetPresentationMode === 'function' ||
|
||||
webkitPiP(document.createElement('video')));
|
||||
}
|
||||
return webkitProbe;
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
|
||||
try {
|
||||
if (document.pictureInPictureElement === video) {
|
||||
await document.exitPictureInPicture();
|
||||
} else {
|
||||
// Deliberately the method's presence rather than
|
||||
// video.webkitSupportsPresentationMode(): that answers false until a video
|
||||
// track is loaded, and feed videos are preload="none" until they go active
|
||||
// -- it would hide the button on exactly the videos about to be able to
|
||||
// use it.
|
||||
App.customPlayer.supportsPiP = function() {
|
||||
return standardPiP() || webkitAvailable();
|
||||
};
|
||||
|
||||
App.customPlayer.pipElement = function() {
|
||||
return document.pictureInPictureElement || webkitPipElement || null;
|
||||
};
|
||||
|
||||
App.customPlayer.enterPiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (standardPiP()) {
|
||||
if (video.disablePictureInPicture) return false;
|
||||
try {
|
||||
await video.requestPictureInPicture();
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!webkitPiP(video)) return false;
|
||||
try {
|
||||
// Synchronous, and it needs the user gesture that got us here.
|
||||
video.webkitSetPresentationMode(WEBKIT_PIP);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
App.customPlayer.exitPiP = async function() {
|
||||
if (document.pictureInPictureElement) {
|
||||
await document.exitPictureInPicture().catch(() => {});
|
||||
return;
|
||||
}
|
||||
const video = webkitPipElement;
|
||||
if (!webkitPiP(video)) return;
|
||||
try { video.webkitSetPresentationMode('inline'); } catch (err) { /* already gone */ }
|
||||
};
|
||||
|
||||
App.customPlayer.bindPiPEvents = function(video) {
|
||||
if (standardPiP() || !webkitPiP(video)) return function destroy() {};
|
||||
// The same event announces fullscreen and inline, so only a real change
|
||||
// in picture-in-picture-ness is worth reporting.
|
||||
let wasPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
const onChange = function() {
|
||||
const isPip = video.webkitPresentationMode === WEBKIT_PIP;
|
||||
if (isPip === wasPip) return;
|
||||
wasPip = isPip;
|
||||
if (isPip) webkitPipElement = video;
|
||||
else if (webkitPipElement === video) webkitPipElement = null;
|
||||
video.dispatchEvent(new CustomEvent(
|
||||
isPip ? 'enterpictureinpicture' : 'leavepictureinpicture', { bubbles: true }));
|
||||
};
|
||||
video.addEventListener('webkitpresentationmodechanged', onChange);
|
||||
return function destroy() {
|
||||
video.removeEventListener('webkitpresentationmodechanged', onChange);
|
||||
if (webkitPipElement === video) webkitPipElement = null;
|
||||
};
|
||||
};
|
||||
|
||||
App.customPlayer.togglePiP = async function(video) {
|
||||
if (!video) return false;
|
||||
if (App.customPlayer.pipElement() === video) {
|
||||
await App.customPlayer.exitPiP();
|
||||
return true;
|
||||
}
|
||||
return App.customPlayer.enterPiP(video);
|
||||
};
|
||||
|
||||
// Asking for picture-in-picture the moment a tab is hidden is a request
|
||||
// with no user gesture behind it, and browsers refuse those -- which is why
|
||||
// the imperative call below fails silently. `autoPictureInPicture` is the
|
||||
// declarative form made for exactly this: the browser is told in advance
|
||||
// which video should follow the reader out, and does it itself. Safari
|
||||
// honours it outright; Chrome honours it for installed apps. The call is
|
||||
// kept as a fallback for anywhere the flag is ignored but the request is
|
||||
// allowed.
|
||||
App.customPlayer.setAutoPiP = function(video, on) {
|
||||
if (!video) return;
|
||||
try { video.autoPictureInPicture = !!on; } catch (err) { /* unsupported */ }
|
||||
if (on) video.setAttribute('autopictureinpicture', '');
|
||||
else video.removeAttribute('autopictureinpicture');
|
||||
};
|
||||
|
||||
App.customPlayer.bindAutoPiP = function(video) {
|
||||
if (!video) return function destroy() {};
|
||||
App.customPlayer.setAutoPiP(video, true);
|
||||
const unbindEvents = App.customPlayer.bindPiPEvents(video);
|
||||
const trigger = () => {
|
||||
if (document.visibilityState !== 'hidden') return;
|
||||
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
|
||||
if (document.pictureInPictureElement) return;
|
||||
if (!App.customPlayer.supportsPiP() || video.disablePictureInPicture) return;
|
||||
if (App.customPlayer.pipElement()) return;
|
||||
if (video.paused || video.ended) return;
|
||||
video.requestPictureInPicture().catch(() => {});
|
||||
App.customPlayer.enterPiP(video);
|
||||
};
|
||||
document.addEventListener('visibilitychange', trigger);
|
||||
window.addEventListener('pagehide', trigger);
|
||||
return function destroy() {
|
||||
App.customPlayer.setAutoPiP(video, false);
|
||||
unbindEvents();
|
||||
document.removeEventListener('visibilitychange', trigger);
|
||||
window.removeEventListener('pagehide', trigger);
|
||||
};
|
||||
|
||||
@@ -60,6 +60,15 @@ App.enhance = App.enhance || {};
|
||||
if (!grid || !fineHover) return;
|
||||
let dwellTimer = null;
|
||||
let activeCard = null;
|
||||
// The card element alone doesn't identify what is being previewed: the
|
||||
// grid pools its cards, so the same element can come back showing a
|
||||
// different video (via relayout or a new search, neither of which
|
||||
// scrolls, so clearPreview never runs). Remembering the video too keeps
|
||||
// the "already previewing this" check honest.
|
||||
let activeVideo = null;
|
||||
|
||||
const isActive = (card) => card === activeCard &&
|
||||
(!App.videos.getVideoForCard || App.videos.getVideoForCard(card) === activeVideo);
|
||||
|
||||
const clearPreview = () => {
|
||||
if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; }
|
||||
@@ -68,6 +77,7 @@ App.enhance = App.enhance || {};
|
||||
if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); }
|
||||
activeCard.classList.remove('is-previewing');
|
||||
activeCard = null;
|
||||
activeVideo = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -79,7 +89,7 @@ App.enhance = App.enhance || {};
|
||||
const ready = meta && Array.isArray(meta.formats) && meta.formats.length;
|
||||
if (!ready) {
|
||||
// Not resolved yet: kick it off so the *next* hover can preview.
|
||||
if (typeof App.videos.resolveAndProbe === 'function') App.videos.resolveAndProbe(v);
|
||||
if (typeof App.videos.ensureFormats === 'function') App.videos.ensureFormats(v);
|
||||
return;
|
||||
}
|
||||
let url = '';
|
||||
@@ -105,10 +115,14 @@ App.enhance = App.enhance || {};
|
||||
|
||||
grid.addEventListener('pointerover', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
if (!card || card === activeCard) return;
|
||||
if (!card || isActive(card)) return;
|
||||
clearPreview();
|
||||
activeCard = card;
|
||||
dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === card) startPreview(card); }, 600);
|
||||
activeVideo = App.videos.getVideoForCard ? App.videos.getVideoForCard(card) : null;
|
||||
dwellTimer = setTimeout(() => {
|
||||
dwellTimer = null;
|
||||
if (isActive(card)) startPreview(card);
|
||||
}, 600);
|
||||
});
|
||||
grid.addEventListener('pointerout', (e) => {
|
||||
const card = e.target.closest('.video-card');
|
||||
@@ -182,11 +196,19 @@ App.enhance = App.enhance || {};
|
||||
out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } });
|
||||
});
|
||||
}
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
if (channelSelect) {
|
||||
Array.from(channelSelect.options).forEach((opt) => {
|
||||
if (opt.value === channelSelect.value) return;
|
||||
out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } });
|
||||
// The channel list is the picker's (App.ui.channels), not a
|
||||
// <select>'s -- same entries, drawn as palette rows.
|
||||
if (App.ui && App.ui.channels) {
|
||||
const current = App.storage && App.storage.getSession ?
|
||||
App.storage.getSession() : null;
|
||||
const currentId = (current && current.channel) ? current.channel.id : '';
|
||||
App.ui.channels.entries().forEach((entry) => {
|
||||
if (entry.id === currentId) return;
|
||||
out.push({
|
||||
label: entry.label,
|
||||
hint: entry.group || 'Channel',
|
||||
run: () => App.ui.channels.choose(entry.id)
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -4,6 +4,12 @@ App.favorites = App.favorites || {};
|
||||
(function() {
|
||||
const { FAVORITES_KEY, FAVORITES_VISIBILITY_KEY } = App.constants;
|
||||
|
||||
// Both identities of every favorite, in one pass, held until the list
|
||||
// changes (see setAll). This is read once per card built, and re-parsing the
|
||||
// whole favorites list out of localStorage that often is what makes a long
|
||||
// list felt on a scrolling grid.
|
||||
let identityCache = null;
|
||||
|
||||
// Favorites storage helpers.
|
||||
App.favorites.getAll = function() {
|
||||
try {
|
||||
@@ -87,6 +93,7 @@ App.favorites = App.favorites || {};
|
||||
};
|
||||
|
||||
App.favorites.setAll = function(items) {
|
||||
identityCache = null;
|
||||
localStorage.setItem(FAVORITES_KEY, JSON.stringify(items));
|
||||
};
|
||||
|
||||
@@ -181,19 +188,49 @@ App.favorites = App.favorites || {};
|
||||
return { added, skipped, total: favorites.length };
|
||||
};
|
||||
|
||||
const identities = function() {
|
||||
if (!identityCache) {
|
||||
const keys = new Set();
|
||||
const urls = new Set();
|
||||
// getAll may rewrite the list (the repair pass), which clears the
|
||||
// cache -- so build it only after that has run.
|
||||
const items = App.favorites.getAll();
|
||||
items.forEach((item) => {
|
||||
if (!item) return;
|
||||
if (item.key) keys.add(item.key);
|
||||
const urlKey = App.favorites.urlKey(item.url);
|
||||
if (urlKey) urls.add(urlKey);
|
||||
});
|
||||
identityCache = { keys: keys, urls: urls };
|
||||
}
|
||||
return identityCache;
|
||||
};
|
||||
|
||||
App.favorites.getSet = function() {
|
||||
return new Set(App.favorites.getAll().map((item) => item.key));
|
||||
return identities().keys;
|
||||
};
|
||||
|
||||
// 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;
|
||||
return identities().urls;
|
||||
};
|
||||
|
||||
// Is this video a favorite, under either identity? A video reaches us from
|
||||
// the listing keyed by the server's id and from a Hot Tub backup keyed by
|
||||
// its URL, and the same video must light up its heart whichever way the
|
||||
// copy on disk got there.
|
||||
App.favorites.has = function(video) {
|
||||
const index = identities();
|
||||
const key = App.favorites.getKey(video);
|
||||
if (key && index.keys.has(key)) return true;
|
||||
// Normalising a URL means parsing one, which is the expensive half of
|
||||
// this and runs for every card that isn't a favorite by key. With
|
||||
// nothing stored to match against, there is nothing to parse it for.
|
||||
if (!index.urls.size) return false;
|
||||
const meta = (video && video.meta) || video || {};
|
||||
const urlKey = App.favorites.urlKey(video && (video.url || meta.url));
|
||||
return !!(urlKey && index.urls.has(urlKey));
|
||||
};
|
||||
|
||||
// Is this video already a favorite, whichever way it got saved? Checked by
|
||||
@@ -209,6 +246,58 @@ App.favorites = App.favorites || {};
|
||||
return favorites.findIndex((item) => item && App.favorites.urlKey(item.url) === urlKey);
|
||||
};
|
||||
|
||||
// Everything an entry carries, so a change can be told from a no-op.
|
||||
const ENTRY_FIELDS = ['key', 'id', 'url', 'title', 'thumb', 'channel',
|
||||
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
||||
|
||||
const sameEntry = function(a, b) {
|
||||
return ENTRY_FIELDS.every((field) =>
|
||||
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
|
||||
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
||||
};
|
||||
|
||||
// Brings stored favorites up to date from a page of listing videos.
|
||||
//
|
||||
// The same video reaches this client under two identities: saved from a
|
||||
// card it carries the server's id, imported from a Hot Tub backup it
|
||||
// carries only its URL. Matching on the URL is what recognises them as one
|
||||
// thing -- and once they are matched, the listing's copy is the better one.
|
||||
// It has the id every card keys on, and a thumbnail URL that hasn't been
|
||||
// sitting in localStorage since whenever the backup was taken. So the
|
||||
// stored entry is replaced by it, keeping only the date it was first saved:
|
||||
// that is the one fact the listing doesn't know and the favorites sort
|
||||
// depends on.
|
||||
//
|
||||
// Writes only when something actually differs, so the steady state of
|
||||
// scrolling a listing full of favorites is no writes at all.
|
||||
App.favorites.reconcile = function(videos) {
|
||||
const items = Array.isArray(videos) ? videos : [];
|
||||
if (!items.length) return 0;
|
||||
const favorites = App.favorites.getAll();
|
||||
if (!favorites.length) return 0;
|
||||
|
||||
let changed = 0;
|
||||
items.forEach((video) => {
|
||||
if (!video || !video.url) return;
|
||||
const index = App.favorites.indexOfEntry(favorites, video);
|
||||
if (index < 0) return;
|
||||
const existing = favorites[index];
|
||||
const upgraded = App.favorites.normalize(Object.assign({}, video, {
|
||||
favoriteDate: existing.favoriteDate
|
||||
}));
|
||||
if (!upgraded || sameEntry(existing, upgraded)) return;
|
||||
favorites[index] = upgraded;
|
||||
changed++;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
App.favorites.setAll(favorites);
|
||||
App.favorites.renderBar();
|
||||
App.favorites.syncButtons();
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
App.favorites.isVisible = function() {
|
||||
return localStorage.getItem(FAVORITES_VISIBILITY_KEY) !== 'false';
|
||||
};
|
||||
@@ -274,6 +363,61 @@ App.favorites = App.favorites || {};
|
||||
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() {
|
||||
const bar = document.getElementById('favorites-bar');
|
||||
const list = document.getElementById('favorites-list');
|
||||
@@ -296,7 +440,16 @@ App.favorites = App.favorites || {};
|
||||
|
||||
// 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);
|
||||
@@ -328,7 +481,7 @@ App.favorites = App.favorites || {};
|
||||
<button class="video-menu-item" type="button" data-action="download" role="menuitem">Download</button>
|
||||
</div>
|
||||
<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-spinner"></div>
|
||||
</div>
|
||||
@@ -336,12 +489,12 @@ App.favorites = App.favorites || {};
|
||||
${durationText ? `<span class="video-duration">${durationText}</span>` : ''}
|
||||
</div>
|
||||
<div class="favorite-info">
|
||||
<h4>${item.title}</h4>
|
||||
<h4 class="favorite-title"><span class="favorite-title-text">${item.title}</span></h4>
|
||||
</div>
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
if (App.videos && typeof App.videos.attachNoReferrerRetry === 'function') {
|
||||
App.videos.attachNoReferrerRetry(thumb);
|
||||
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
||||
App.videos.attachThumbnail(thumb, item.thumb);
|
||||
}
|
||||
card.onclick = () => {
|
||||
if (card.classList.contains('is-loading')) return;
|
||||
@@ -372,9 +525,10 @@ App.favorites = App.favorites || {};
|
||||
showInfoBtn.onclick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.videos.closeAllMenus();
|
||||
// Favorites deliberately store no resolved metadata, so pull
|
||||
// it fresh before showing the full info dump.
|
||||
App.videos.ensureFormats(item).then(() => App.ui.showInfo(item));
|
||||
// Favorites deliberately store no resolved metadata; the
|
||||
// panel opens on what the entry holds and resolves the rest
|
||||
// itself.
|
||||
App.ui.openInfo(item);
|
||||
};
|
||||
}
|
||||
if (downloadBtn) {
|
||||
@@ -394,7 +548,17 @@ App.favorites = App.favorites || {};
|
||||
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);
|
||||
});
|
||||
// Widths only exist once the cards are laid out.
|
||||
requestAnimationFrame(() => measureBarTitles(list));
|
||||
}
|
||||
})();
|
||||
|
||||
1005
frontend/js/feed.js
1005
frontend/js/feed.js
File diff suppressed because it is too large
Load Diff
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;
|
||||
};
|
||||
})();
|
||||
@@ -23,9 +23,63 @@ App.player = App.player || {};
|
||||
historyPushed: false,
|
||||
idleTimer: null,
|
||||
originEl: null,
|
||||
originToken: null, // stamp proving originEl is still the card we opened
|
||||
hudHovered: false, // mouse resting on the controls (desktop)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// The card the player was opened from is owned by the grid, which pools and
|
||||
// reuses its cards (see resetCard in videos.js). By the time the player lets
|
||||
// go, that element may be showing a different video -- so it is stamped at
|
||||
// open, and every later touch checks the stamp still matches. A recycled
|
||||
// card has had it wiped, and simply stops answering.
|
||||
let originSeq = 0;
|
||||
|
||||
const claimOrigin = function(el) {
|
||||
if (!el) return null;
|
||||
const token = String(++originSeq);
|
||||
el.dataset.playerToken = token;
|
||||
return token;
|
||||
};
|
||||
|
||||
const withOrigin = function(el, token, fn) {
|
||||
// `token` must be truthy in its own right: dataset yields undefined for
|
||||
// a missing attribute, so without this an unstamped token would match
|
||||
// every card that has no stamp -- including one the grid has recycled,
|
||||
// which is precisely the case this guard exists to catch.
|
||||
if (el && token && el.dataset.playerToken === token) fn(el);
|
||||
};
|
||||
|
||||
const addCleanup = (fn) => cp.cleanups.push(fn);
|
||||
@@ -111,26 +165,11 @@ App.player = App.player || {};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Title marquee (mirrors App.videos' card marquee math, scoped locally
|
||||
// since the player title isn't a `.video-card`).
|
||||
// Title marquee. Only one title is on screen here, so unlike the grid it
|
||||
// always scrolls when it overflows -- there's nothing to pick between.
|
||||
// ---------------------------------------------------------------------
|
||||
function measureTitle() {
|
||||
const wrap = q('.cp-title');
|
||||
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');
|
||||
}
|
||||
App.marquee.measure(q('.cp-title'), q('.cp-title-text'));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -279,7 +318,7 @@ App.player = App.player || {};
|
||||
const key = App.favorites.getKey(videoData);
|
||||
if (!key) { btn.hidden = true; return; }
|
||||
btn.dataset.favKey = key;
|
||||
App.favorites.setButtonState(btn, App.favorites.getSet().has(key));
|
||||
App.favorites.setButtonState(btn, App.favorites.has(videoData));
|
||||
const onClick = (event) => {
|
||||
event.stopPropagation();
|
||||
App.favorites.toggle(videoData);
|
||||
@@ -667,6 +706,10 @@ App.player = App.player || {};
|
||||
function playSources(videoData, opts) {
|
||||
const video = cp.video;
|
||||
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;
|
||||
// Captured once per call rather than read from the shared `cp`
|
||||
// object later: if open() is ever re-entered for a different video
|
||||
@@ -675,9 +718,10 @@ App.player = App.player || {};
|
||||
// the wrong card loaded or (via the token guard below) never clear
|
||||
// this card's spinner at all.
|
||||
const originEl = (opts && opts.originEl) || null;
|
||||
const originToken = originEl ? originEl.dataset.playerToken : null;
|
||||
const sources = resolveSources(videoData);
|
||||
const clearLoading = () => {
|
||||
if (originEl) originEl.classList.remove('is-loading');
|
||||
withOrigin(originEl, originToken, (el) => el.classList.remove('is-loading'));
|
||||
};
|
||||
const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || '';
|
||||
|
||||
@@ -694,6 +738,39 @@ App.player = App.player || {};
|
||||
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) => {
|
||||
if (token !== cp.attemptToken) return;
|
||||
const entry = plan[index];
|
||||
@@ -738,16 +815,13 @@ App.player = App.player || {};
|
||||
let isHls = kind.isHls;
|
||||
let isDirectMedia = kind.isDirectMedia;
|
||||
|
||||
video.onerror = null;
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.stopLoad();
|
||||
state.hlsPlayer.detachMedia();
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
video.pause();
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
cancelInFlight();
|
||||
const attemptAbort = new AbortController();
|
||||
cp.fetchAbort = attemptAbort;
|
||||
|
||||
// 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
|
||||
@@ -755,14 +829,23 @@ App.player = App.player || {};
|
||||
// extractor's protocol says what this source is.
|
||||
if (!isHls && !isDirectMedia && !entry.direct) {
|
||||
try {
|
||||
const headResp = await fetch(streamUrl, { method: 'HEAD' });
|
||||
if (token !== cp.attemptToken) return;
|
||||
const headResp = await fetch(streamUrl, {
|
||||
method: 'HEAD',
|
||||
signal: attemptAbort.signal
|
||||
});
|
||||
const contentType = headResp.headers.get('Content-Type') || '';
|
||||
if (contentType.includes('application/vnd.apple.mpegurl')) isHls = true;
|
||||
else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) isDirectMedia = true;
|
||||
} 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 = () => {
|
||||
@@ -860,20 +943,14 @@ App.player = App.player || {};
|
||||
const reopening = !!(cp.container && cp.container.classList.contains('open'));
|
||||
if (reopening) {
|
||||
cp.attemptToken++;
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
if (cp.video) {
|
||||
cp.video.onerror = null;
|
||||
cp.video.pause();
|
||||
}
|
||||
cancelInFlight();
|
||||
clearIdleTimer();
|
||||
if (cp.originEl) cp.originEl.classList.remove('is-loading');
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
|
||||
}
|
||||
runCleanups();
|
||||
|
||||
cp.originEl = opts && opts.originEl ? opts.originEl : null;
|
||||
cp.originToken = claimOrigin(cp.originEl);
|
||||
if (cp.originEl) cp.originEl.classList.add('is-loading');
|
||||
|
||||
cp.container = buildContainer();
|
||||
@@ -959,17 +1036,10 @@ App.player = App.player || {};
|
||||
App.player.close = function(opts) {
|
||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
||||
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
||||
|
||||
if (state.hlsPlayer) {
|
||||
state.hlsPlayer.destroy();
|
||||
state.hlsPlayer = null;
|
||||
}
|
||||
if (cp.video) {
|
||||
cp.video.onerror = null;
|
||||
cp.video.pause();
|
||||
cp.video.removeAttribute('src');
|
||||
cp.video.load();
|
||||
}
|
||||
// Closing the player must also stop what it was fetching -- otherwise a
|
||||
// proxied stream keeps being pulled, and the server keeps an upstream
|
||||
// connection open, for a video nobody is watching any more.
|
||||
cancelInFlight();
|
||||
clearIdleTimer();
|
||||
runCleanups();
|
||||
|
||||
@@ -986,10 +1056,12 @@ App.player = App.player || {};
|
||||
cp.historyPushed = false;
|
||||
}
|
||||
|
||||
if (cp.originEl) {
|
||||
cp.originEl.classList.remove('is-loading');
|
||||
cp.originEl = null;
|
||||
}
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => {
|
||||
el.classList.remove('is-loading');
|
||||
delete el.dataset.playerToken;
|
||||
});
|
||||
cp.originEl = null;
|
||||
cp.originToken = null;
|
||||
cp.data = null;
|
||||
cp.source = null; // voids a still-pending format resolve for this open
|
||||
cp.formatOverride = null;
|
||||
|
||||
@@ -66,61 +66,119 @@ App.ui = App.ui || {};
|
||||
}, 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');
|
||||
if (!modal) return;
|
||||
const opts = options || {};
|
||||
const title = document.getElementById('info-title');
|
||||
const list = document.getElementById('info-list');
|
||||
const empty = document.getElementById('info-empty');
|
||||
|
||||
const data = video && video.meta ? video.meta : video;
|
||||
const titleText = data && data.title ? data.title : 'Video Info';
|
||||
if (title) title.textContent = titleText;
|
||||
const item = (video && typeof video === 'object') ? video : {};
|
||||
// `meta` is the trimmed playback payload; the full extractor info is a
|
||||
// superset of it, so only one of the two is ever shown.
|
||||
const resolved = opts.info || item.meta || null;
|
||||
|
||||
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
|
||||
|
||||
let rows = 0;
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
}
|
||||
// `meta` gets its own section below rather than a row of JSON.
|
||||
const own = Object.assign({}, item);
|
||||
delete own.meta;
|
||||
rows += appendInfoRows(list, own);
|
||||
|
||||
let hasRows = false;
|
||||
if (data && typeof data === 'object') {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (!list) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'info-row';
|
||||
if (resolved && typeof resolved === 'object') {
|
||||
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
|
||||
rows += appendInfoRows(list, resolved);
|
||||
}
|
||||
|
||||
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);
|
||||
hasRows = true;
|
||||
});
|
||||
if (opts.pending) {
|
||||
const pending = document.createElement('div');
|
||||
pending.className = 'info-pending';
|
||||
pending.textContent = 'Resolving full metadata…';
|
||||
list.appendChild(pending);
|
||||
}
|
||||
}
|
||||
|
||||
if (empty) {
|
||||
empty.style.display = hasRows ? 'none' : 'block';
|
||||
empty.style.display = rows ? 'none' : 'block';
|
||||
}
|
||||
|
||||
modal.classList.add('open');
|
||||
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() {
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
infoVideo = null;
|
||||
modal.classList.remove('open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
@@ -171,12 +229,356 @@ App.ui = App.ui || {};
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Channel picker
|
||||
//
|
||||
// A <select> of ninety channels tells the reader almost nothing: a wall of
|
||||
// bare names, nothing to say which site a name belongs to or what it
|
||||
// carries, and no way to look for one. The server sends far more than the
|
||||
// name -- a favicon, a description, tags, whether the channel still says
|
||||
// "work in progress" -- so the picker shows that, and lets the reader type.
|
||||
//
|
||||
// The <select> was also where the command palette read its channel actions
|
||||
// from, so the list lives here now and the palette asks for it.
|
||||
// ---------------------------------------------------------------------
|
||||
App.ui.channels = (function() {
|
||||
// Sections in display order: a group, then whatever the server didn't
|
||||
// put in one. Rebuilt whenever the menu renders, which is whenever the
|
||||
// server, its status, or the selection changes.
|
||||
let sections = [];
|
||||
let matched = []; // the rows the current search leaves on screen
|
||||
let activeIndex = 0;
|
||||
let bound = false;
|
||||
|
||||
const el = (id) => document.getElementById(id);
|
||||
|
||||
const activeServerData = function() {
|
||||
const session = App.storage.getSession();
|
||||
if (!session) return null;
|
||||
const entry = App.storage.getServerEntries()
|
||||
.find((candidate) => candidate.url === session.server);
|
||||
return (entry && entry.data) || null;
|
||||
};
|
||||
|
||||
// Everything about a channel worth searching, in one string: a reader
|
||||
// typing "jav", "leaks" or the site's own name should all land.
|
||||
const haystack = function(parts) {
|
||||
return parts.filter(Boolean).join(' ').toLowerCase();
|
||||
};
|
||||
|
||||
const channelRow = function(channel, groupTitle) {
|
||||
const tags = Array.isArray(channel.tags) ? channel.tags : [];
|
||||
return {
|
||||
id: channel.id,
|
||||
name: channel.name || channel.id,
|
||||
note: channel.description || '',
|
||||
favicon: channel.favicon || '',
|
||||
tags: tags.slice(0, 3),
|
||||
// "work in progress" is the server's own word for a channel
|
||||
// that may not answer; worth saying before it's picked.
|
||||
flag: channel.status && channel.status !== 'active' ? channel.status :
|
||||
(channel.premium ? 'premium' : ''),
|
||||
group: groupTitle || '',
|
||||
search: haystack([channel.name, channel.id, channel.description,
|
||||
tags.join(' '), groupTitle])
|
||||
};
|
||||
};
|
||||
|
||||
const build = function() {
|
||||
const data = activeServerData();
|
||||
const channels = (data && Array.isArray(data.channels)) ? data.channels : [];
|
||||
const groups = (data && Array.isArray(data.channelGroups)) ? data.channelGroups : [];
|
||||
const byId = new Map(channels.map((channel) => [channel.id, channel]));
|
||||
const grouped = new Set();
|
||||
sections = [];
|
||||
|
||||
groups.forEach((group) => {
|
||||
const ids = (Array.isArray(group.channelIds) ? group.channelIds : [])
|
||||
.filter((id) => byId.has(id));
|
||||
if (!ids.length) return;
|
||||
ids.forEach((id) => grouped.add(id));
|
||||
const title = group.title || group.id;
|
||||
sections.push({
|
||||
title: title,
|
||||
rows: [{
|
||||
id: `group:${group.id}`,
|
||||
name: `All ${title}`,
|
||||
// Every group row would otherwise be an "A" for "All".
|
||||
mark: title,
|
||||
note: `Every channel in ${title}, interleaved.`,
|
||||
favicon: '',
|
||||
tags: [],
|
||||
flag: ids.length === 1 ? '1 channel' : `${ids.length} channels`,
|
||||
group: title,
|
||||
search: haystack(['all', title, group.id])
|
||||
}].concat(ids.map((id) => channelRow(byId.get(id), title)))
|
||||
});
|
||||
});
|
||||
|
||||
const ungrouped = channels
|
||||
.filter((channel) => !grouped.has(channel.id))
|
||||
.sort((a, b) => (a.name || a.id || '').toLowerCase()
|
||||
.localeCompare((b.name || b.id || '').toLowerCase()));
|
||||
if (ungrouped.length) {
|
||||
sections.push({
|
||||
title: sections.length ? 'Everything else' : 'Channels',
|
||||
rows: ungrouped.map((channel) => channelRow(channel, ''))
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// The letter behind a favicon that hasn't arrived (or never will), so a
|
||||
// row is never a name next to an empty square.
|
||||
const markFor = function(row) {
|
||||
const mark = document.createElement('span');
|
||||
mark.className = 'channel-mark';
|
||||
mark.dataset.letter = (row.mark || row.name || '?').trim().charAt(0).toUpperCase();
|
||||
if (row.favicon) {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'channel-favicon';
|
||||
img.alt = '';
|
||||
img.loading = 'lazy';
|
||||
img.decoding = 'async';
|
||||
mark.appendChild(img);
|
||||
// The same route race, proxy fallback and retries every other
|
||||
// remote picture in the app goes through.
|
||||
App.videos.attachThumbnail(img, row.favicon);
|
||||
}
|
||||
return mark;
|
||||
};
|
||||
|
||||
const rowButton = function(row, currentId) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'channel-row' + (row.id === currentId ? ' is-current' : '');
|
||||
button.setAttribute('role', 'option');
|
||||
button.setAttribute('aria-selected', row.id === currentId ? 'true' : 'false');
|
||||
button.dataset.channelId = row.id;
|
||||
button.appendChild(markFor(row));
|
||||
|
||||
const text = document.createElement('span');
|
||||
text.className = 'channel-row-text';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'channel-row-name';
|
||||
name.textContent = row.name;
|
||||
text.appendChild(name);
|
||||
if (row.note) {
|
||||
const note = document.createElement('span');
|
||||
note.className = 'channel-row-note';
|
||||
note.textContent = row.note;
|
||||
text.appendChild(note);
|
||||
}
|
||||
if (row.tags.length) {
|
||||
const tags = document.createElement('span');
|
||||
tags.className = 'channel-row-tags';
|
||||
row.tags.forEach((tag) => {
|
||||
const chip = document.createElement('span');
|
||||
chip.className = 'channel-tag';
|
||||
chip.textContent = tag;
|
||||
tags.appendChild(chip);
|
||||
});
|
||||
text.appendChild(tags);
|
||||
}
|
||||
button.appendChild(text);
|
||||
|
||||
if (row.flag) {
|
||||
const flag = document.createElement('span');
|
||||
flag.className = 'channel-row-flag';
|
||||
flag.textContent = row.flag;
|
||||
button.appendChild(flag);
|
||||
}
|
||||
return button;
|
||||
};
|
||||
|
||||
const render = function() {
|
||||
const list = el('channel-picker-list');
|
||||
const empty = el('channel-picker-empty');
|
||||
const search = el('channel-search');
|
||||
if (!list) return;
|
||||
const query = (search ? search.value : '').trim().toLowerCase();
|
||||
const session = App.storage.getSession();
|
||||
const currentId = (session && session.channel) ? session.channel.id : '';
|
||||
|
||||
list.innerHTML = '';
|
||||
matched = [];
|
||||
sections.forEach((section) => {
|
||||
const rows = query
|
||||
? section.rows.filter((row) => row.search.includes(query))
|
||||
: section.rows;
|
||||
if (!rows.length) return;
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'channel-section';
|
||||
heading.textContent = section.title;
|
||||
list.appendChild(heading);
|
||||
rows.forEach((row) => {
|
||||
const button = rowButton(row, currentId);
|
||||
const index = matched.length;
|
||||
button.addEventListener('click', () => App.ui.channels.choose(row.id));
|
||||
button.addEventListener('pointermove', () => setActive(index));
|
||||
list.appendChild(button);
|
||||
matched.push(button);
|
||||
});
|
||||
});
|
||||
if (empty) empty.hidden = matched.length > 0;
|
||||
// A shorter list under an unchanged scroll position hides its own
|
||||
// first hits, so every search starts back at the top.
|
||||
if (query) list.scrollTop = 0;
|
||||
// A search starts on its first hit; an unsearched list starts on the
|
||||
// channel already being read, which is also what gets scrolled to.
|
||||
const current = matched.findIndex((button) => button.classList.contains('is-current'));
|
||||
setActive(query || current < 0 ? 0 : current, !query);
|
||||
};
|
||||
|
||||
const setActive = function(index, scroll) {
|
||||
if (!matched.length) { activeIndex = 0; return; }
|
||||
activeIndex = Math.max(0, Math.min(index, matched.length - 1));
|
||||
matched.forEach((button, i) => button.classList.toggle('is-active', i === activeIndex));
|
||||
if (scroll && matched[activeIndex]) {
|
||||
matched[activeIndex].scrollIntoView({ block: 'center' });
|
||||
}
|
||||
};
|
||||
|
||||
const step = function(delta) {
|
||||
setActive(activeIndex + delta);
|
||||
const button = matched[activeIndex];
|
||||
if (button) button.scrollIntoView({ block: 'nearest' });
|
||||
};
|
||||
|
||||
const open = function() {
|
||||
const picker = el('channel-picker');
|
||||
const search = el('channel-search');
|
||||
const trigger = el('channel-picker-btn');
|
||||
if (!picker) return;
|
||||
build();
|
||||
if (search) search.value = '';
|
||||
// Shown before it is filled: scrolling the current channel into
|
||||
// view can't work while the list is still display:none.
|
||||
picker.classList.add('open');
|
||||
picker.setAttribute('aria-hidden', 'false');
|
||||
render();
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
||||
// Typing is the point of the thing -- but not on a phone, where
|
||||
// focusing the field throws up the keyboard over the list.
|
||||
if (search && window.matchMedia('(min-width: 720px)').matches) {
|
||||
requestAnimationFrame(() => search.focus());
|
||||
}
|
||||
};
|
||||
|
||||
const close = function() {
|
||||
const picker = el('channel-picker');
|
||||
const trigger = el('channel-picker-btn');
|
||||
if (picker) {
|
||||
picker.classList.remove('open');
|
||||
picker.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
||||
};
|
||||
|
||||
const bind = function() {
|
||||
if (bound) return;
|
||||
const picker = el('channel-picker');
|
||||
const trigger = el('channel-picker-btn');
|
||||
const search = el('channel-search');
|
||||
const closeBtn = el('channel-picker-close');
|
||||
if (!picker || !trigger) return;
|
||||
bound = true;
|
||||
|
||||
trigger.addEventListener('click', open);
|
||||
if (closeBtn) closeBtn.addEventListener('click', close);
|
||||
picker.addEventListener('click', (event) => { if (event.target === picker) close(); });
|
||||
if (search) {
|
||||
search.addEventListener('input', render);
|
||||
search.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'ArrowDown') { event.preventDefault(); step(1); }
|
||||
else if (event.key === 'ArrowUp') { event.preventDefault(); step(-1); }
|
||||
else if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
const button = matched[activeIndex];
|
||||
if (button) button.click();
|
||||
} else if (event.key === 'Escape') { event.preventDefault(); close(); }
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && picker.classList.contains('open')) close();
|
||||
});
|
||||
};
|
||||
|
||||
// The trigger says what is being read now, with the same favicon the
|
||||
// list shows, so the menu answers the question without being opened.
|
||||
const renderTrigger = function() {
|
||||
const session = App.storage.getSession();
|
||||
const channel = session && session.channel;
|
||||
const name = el('channel-trigger-name');
|
||||
const note = el('channel-trigger-note');
|
||||
const mark = el('channel-trigger-mark');
|
||||
const icon = el('channel-trigger-icon');
|
||||
if (!name || !mark || !icon) return;
|
||||
name.textContent = channel ? (channel.name || channel.id) : 'No channel';
|
||||
mark.dataset.letter = (channel ? (channel.name || channel.id || '?') : '?')
|
||||
.trim().charAt(0).toUpperCase();
|
||||
if (note) {
|
||||
note.textContent = channel
|
||||
? (channel.isGroup ? 'Whole group' : (channel.description || ''))
|
||||
: 'This source has no channels.';
|
||||
}
|
||||
App.videos.detachThumbnail(icon);
|
||||
icon.removeAttribute('src');
|
||||
icon.hidden = !(channel && channel.favicon);
|
||||
if (channel && channel.favicon) App.videos.attachThumbnail(icon, channel.favicon);
|
||||
};
|
||||
|
||||
return {
|
||||
// Called by renderMenu: the picker follows whatever the menu is
|
||||
// showing, and nothing else has to know it exists.
|
||||
render: function() {
|
||||
bind();
|
||||
build();
|
||||
renderTrigger();
|
||||
if (el('channel-picker') && el('channel-picker').classList.contains('open')) {
|
||||
render();
|
||||
}
|
||||
},
|
||||
open: open,
|
||||
close: close,
|
||||
// The flat list the command palette offers alongside its own
|
||||
// actions -- id and label only; it draws its own rows.
|
||||
entries: function() {
|
||||
if (!sections.length) build();
|
||||
const out = [];
|
||||
sections.forEach((section) => section.rows.forEach((row) => {
|
||||
out.push({ id: row.id, label: row.name, group: section.title });
|
||||
}));
|
||||
return out;
|
||||
},
|
||||
choose: function(id) {
|
||||
const session = App.storage.getSession();
|
||||
const data = activeServerData();
|
||||
const nextChannel = data ? App.session.resolveChannelById(data, id) : null;
|
||||
if (!session || !nextChannel) return;
|
||||
const serverPrefs = App.storage.getPreferences()[session.server] || {};
|
||||
const savedOptions = serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] : null;
|
||||
const nextSession = {
|
||||
server: session.server,
|
||||
channel: nextChannel,
|
||||
options: savedOptions ?
|
||||
App.session.hydrateOptions(nextChannel, savedOptions) :
|
||||
App.session.buildDefaultOptions(nextChannel)
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
close();
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// Settings + menu rendering.
|
||||
App.ui.renderMenu = function() {
|
||||
const session = App.storage.getSession();
|
||||
const serverEntries = App.storage.getServerEntries();
|
||||
const sourceSelect = document.getElementById('source-select');
|
||||
const channelSelect = document.getElementById('channel-select');
|
||||
const filtersContainer = document.getElementById('filters-container');
|
||||
const sourcesList = document.getElementById('sources-list');
|
||||
const addSourceBtn = document.getElementById('add-source-btn');
|
||||
@@ -184,7 +586,7 @@ App.ui = App.ui || {};
|
||||
const reloadChannelBtn = document.getElementById('reload-channel-btn');
|
||||
const favoritesToggle = document.getElementById('favorites-toggle');
|
||||
|
||||
if (!sourceSelect || !channelSelect || !filtersContainer) return;
|
||||
if (!sourceSelect || !filtersContainer) return;
|
||||
|
||||
sourceSelect.innerHTML = "";
|
||||
serverEntries.forEach((entry) => {
|
||||
@@ -223,80 +625,7 @@ App.ui = App.ui || {};
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
|
||||
const activeServer = serverEntries.find((entry) => entry.url === (session && session.server));
|
||||
const activeServerData = activeServer && activeServer.data ? activeServer.data : null;
|
||||
const availableChannels = activeServerData && activeServerData.channels ?
|
||||
[...activeServerData.channels] :
|
||||
[];
|
||||
availableChannels.sort((a, b) => {
|
||||
const nameA = (a.name || a.id || '').toLowerCase();
|
||||
const nameB = (b.name || b.id || '').toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
const channelGroups = activeServerData && Array.isArray(activeServerData.channelGroups) ?
|
||||
activeServerData.channelGroups :
|
||||
[];
|
||||
|
||||
channelSelect.innerHTML = "";
|
||||
const groupedChannelIds = new Set();
|
||||
channelGroups.forEach((group) => {
|
||||
const channelIds = Array.isArray(group.channelIds) ?
|
||||
group.channelIds.filter((id) => availableChannels.some((channel) => channel.id === id)) :
|
||||
[];
|
||||
if (channelIds.length === 0) return;
|
||||
channelIds.forEach((id) => groupedChannelIds.add(id));
|
||||
|
||||
const optgroup = document.createElement('optgroup');
|
||||
optgroup.label = group.title || group.id;
|
||||
|
||||
const groupOption = document.createElement('option');
|
||||
groupOption.value = `group:${group.id}`;
|
||||
groupOption.textContent = `All ${group.title || group.id}`;
|
||||
optgroup.appendChild(groupOption);
|
||||
|
||||
channelIds.forEach((id) => {
|
||||
const channel = availableChannels.find((ch) => ch.id === id);
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.id;
|
||||
option.textContent = channel.name || channel.id;
|
||||
optgroup.appendChild(option);
|
||||
});
|
||||
|
||||
channelSelect.appendChild(optgroup);
|
||||
});
|
||||
|
||||
availableChannels
|
||||
.filter((channel) => !groupedChannelIds.has(channel.id))
|
||||
.forEach((channel) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel.id;
|
||||
option.textContent = channel.name || channel.id;
|
||||
channelSelect.appendChild(option);
|
||||
});
|
||||
|
||||
if (session && session.channel) {
|
||||
channelSelect.value = session.channel.id;
|
||||
}
|
||||
|
||||
channelSelect.onchange = () => {
|
||||
const selectedId = channelSelect.value;
|
||||
const nextChannel = activeServerData ? App.session.resolveChannelById(activeServerData, selectedId) : null;
|
||||
const prefs = App.storage.getPreferences();
|
||||
const serverPrefs = prefs[session.server] || {};
|
||||
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
|
||||
serverPrefs.optionsByChannel[nextChannel.id] :
|
||||
null;
|
||||
const nextSession = {
|
||||
server: session.server,
|
||||
channel: nextChannel,
|
||||
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
||||
};
|
||||
App.storage.setSession(nextSession);
|
||||
App.session.savePreference(nextSession);
|
||||
App.ui.renderMenu();
|
||||
App.videos.resetAndReload();
|
||||
};
|
||||
App.ui.channels.render();
|
||||
|
||||
App.ui.renderFilters(filtersContainer, session);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
199
tests/smoke_channels.py
Normal file
199
tests/smoke_channels.py
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Channel picker smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_channels.py
|
||||
|
||||
The picker replaced a <select>, so what's checked here is what the <select>
|
||||
couldn't do -- show what a channel *is* (favicon, description, tags, group) and
|
||||
let it be searched -- plus the two things it could: switching the channel, and
|
||||
feeding the command palette its list.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottub.spacemoehre.de"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
ROWS = """() => {
|
||||
const list = document.getElementById('channel-picker-list');
|
||||
const rows = Array.from(list.querySelectorAll('.channel-row'));
|
||||
return {
|
||||
sections: Array.from(list.querySelectorAll('.channel-section')).map((s) => s.textContent),
|
||||
count: rows.length,
|
||||
withNote: rows.filter((r) => !!r.querySelector('.channel-row-note')).length,
|
||||
withTags: rows.filter((r) => !!r.querySelector('.channel-tag')).length,
|
||||
withIconEl: rows.filter((r) => !!r.querySelector('img.channel-favicon')).length,
|
||||
iconsLoaded: rows.filter((r) => {
|
||||
const img = r.querySelector('img.channel-favicon');
|
||||
return img && img.naturalWidth > 0;
|
||||
}).length,
|
||||
current: rows.filter((r) => r.classList.contains('is-current'))
|
||||
.map((r) => r.dataset.channelId),
|
||||
groupRows: rows.filter((r) => r.dataset.channelId.startsWith('group:')).length,
|
||||
emptyHidden: document.getElementById('channel-picker-empty').hidden,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def boot(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(2500)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
boot(page)
|
||||
|
||||
print("\nthe menu says which channel is being read")
|
||||
page.evaluate("() => App.ui.toggleDrawer('menu')")
|
||||
page.wait_for_timeout(2500)
|
||||
trigger = page.evaluate("""() => ({
|
||||
name: document.getElementById('channel-trigger-name').textContent,
|
||||
note: document.getElementById('channel-trigger-note').textContent,
|
||||
iconLoaded: document.getElementById('channel-trigger-icon').naturalWidth > 0,
|
||||
})""")
|
||||
c.ok("the trigger names the current channel", trigger["name"] == "XVideos", str(trigger))
|
||||
c.ok("and says something about it", len(trigger["note"]) > 10, str(trigger))
|
||||
c.ok("and shows its favicon", trigger["iconLoaded"], str(trigger))
|
||||
|
||||
print("\nopening it lists the channels with what the server knows")
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(3000)
|
||||
rows = page.evaluate(ROWS)
|
||||
c.ok("every channel is listed", rows["count"] > 50, str(rows["count"]))
|
||||
c.ok("grouped under the server's own headings", len(rows["sections"]) > 1,
|
||||
str(rows["sections"][:4]))
|
||||
c.ok("each group can be browsed whole", rows["groupRows"] > 1, str(rows["groupRows"]))
|
||||
c.ok("rows carry a description", rows["withNote"] > rows["count"] // 2, str(rows))
|
||||
c.ok("rows carry tags", rows["withTags"] > rows["count"] // 2, str(rows))
|
||||
c.ok("rows carry a favicon", rows["withIconEl"] > rows["count"] // 2, str(rows))
|
||||
c.ok("and the favicons actually load", rows["iconsLoaded"] > 10,
|
||||
f"{rows['iconsLoaded']} of {rows['withIconEl']} loaded")
|
||||
c.ok("the current channel is marked", rows["current"] == [CHANNEL], str(rows["current"]))
|
||||
|
||||
print("\nsearching narrows it")
|
||||
page.fill("#channel-search", "hentai")
|
||||
page.wait_for_timeout(600)
|
||||
found = page.evaluate(ROWS)
|
||||
c.ok("fewer rows than before", 0 < found["count"] < rows["count"], str(found["count"]))
|
||||
# A row can match on its own text or on the group it sits under -- the
|
||||
# heading is as much a fact about the channel as its description.
|
||||
c.ok("every row left standing matches what was typed",
|
||||
page.evaluate("""() => {
|
||||
let heading = '';
|
||||
return Array.from(document.getElementById('channel-picker-list').children)
|
||||
.every((node) => {
|
||||
if (node.classList.contains('channel-section')) {
|
||||
heading = node.textContent.toLowerCase();
|
||||
return true;
|
||||
}
|
||||
const text = (node.textContent + ' ' + node.dataset.channelId + ' ' + heading);
|
||||
return text.toLowerCase().includes('hentai');
|
||||
});
|
||||
}"""))
|
||||
# Description and tags are searched too, not just the name.
|
||||
page.fill("#channel-search", "leaks")
|
||||
page.wait_for_timeout(600)
|
||||
by_tag = page.evaluate(ROWS)
|
||||
c.ok("a tag finds channels whose name doesn't say it", by_tag["count"] > 0,
|
||||
str(by_tag["count"]))
|
||||
|
||||
page.fill("#channel-search", "zzzznothing")
|
||||
page.wait_for_timeout(600)
|
||||
nothing = page.evaluate(ROWS)
|
||||
c.ok("a search with no hits says so",
|
||||
nothing["count"] == 0 and not nothing["emptyHidden"], str(nothing))
|
||||
|
||||
print("\npicking one switches the feed")
|
||||
page.fill("#channel-search", "eporner")
|
||||
page.wait_for_timeout(600)
|
||||
page.click(".channel-row")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("the picker closes",
|
||||
page.evaluate("() => !document.getElementById('channel-picker').classList.contains('open')"))
|
||||
session = page.evaluate("() => App.storage.getSession().channel.id")
|
||||
c.ok("the session moved to it", session == "eporner", session)
|
||||
c.ok("the trigger followed",
|
||||
page.evaluate("() => document.getElementById('channel-trigger-name').textContent") == "EPorner")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
c.ok("and the grid reloaded",
|
||||
page.evaluate("() => App.state.loadedVideos.length > 0"))
|
||||
|
||||
print("\nthe keyboard works too")
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(1200)
|
||||
page.fill("#channel-search", "beeg")
|
||||
page.wait_for_timeout(500)
|
||||
page.press("#channel-search", "ArrowDown")
|
||||
page.press("#channel-search", "ArrowUp")
|
||||
page.press("#channel-search", "Enter")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("Enter picks the highlighted row",
|
||||
page.evaluate("() => App.storage.getSession().channel.id") == "beeg",
|
||||
page.evaluate("() => App.storage.getSession().channel.id"))
|
||||
|
||||
page.click("#channel-picker-btn")
|
||||
page.wait_for_timeout(800)
|
||||
page.press("#channel-search", "Escape")
|
||||
page.wait_for_timeout(300)
|
||||
c.ok("Escape closes it without changing anything",
|
||||
page.evaluate("""() => !document.getElementById('channel-picker').classList.contains('open')
|
||||
&& App.storage.getSession().channel.id === 'beeg'"""))
|
||||
|
||||
print("\nthe command palette still offers the channels")
|
||||
entries = page.evaluate("() => App.ui.channels.entries().length")
|
||||
c.ok("the picker hands out its list", entries > 50, str(entries))
|
||||
page.evaluate("() => App.enhance.openPalette()")
|
||||
page.wait_for_timeout(400)
|
||||
page.fill("#cmdk-input", "Redtube")
|
||||
page.wait_for_timeout(400)
|
||||
page.press("#cmdk-input", "Enter")
|
||||
page.wait_for_timeout(1500)
|
||||
c.ok("and choosing one from there switches the channel",
|
||||
page.evaluate("() => App.storage.getSession().channel.id") == "redtube",
|
||||
page.evaluate("() => App.storage.getSession().channel.id"))
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
175
tests/smoke_docpip.py
Normal file
175
tests/smoke_docpip.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Document picture-in-picture: the reel, in a window, scrolling.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_docpip.py
|
||||
|
||||
A video picture-in-picture window renders one <video>'s frames and cannot
|
||||
scroll. Document picture-in-picture opens a real document instead, so the feed
|
||||
is *moved* into it -- the same elements, another window. That is what these
|
||||
checks are about: the move must be a move (no rebuild, playback intact), the
|
||||
window must scroll the reel for real, and closing it must put the feed back
|
||||
where it came from.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# The feed can be read from either document. Only App.state lives in the page --
|
||||
# the window's document has no scripts of its own; it holds the moved elements,
|
||||
# whose handlers are still the page's closures. That is the whole design, so the
|
||||
# probe takes the document to look in and always runs in the page.
|
||||
PROBE = """(where) => {
|
||||
const doc = where === 'pip'
|
||||
? (documentPictureInPicture.window && documentPictureInPicture.window.document)
|
||||
: document;
|
||||
if (!doc) return null;
|
||||
const root = doc.getElementById('feed-view');
|
||||
const active = doc.querySelector('.feed-slide.is-active');
|
||||
const scroller = doc.getElementById('feed-scroll');
|
||||
return {
|
||||
rooted_here: !!root,
|
||||
slides: doc.querySelectorAll('.feed-slide').length,
|
||||
step: App.state.feedActiveIndex,
|
||||
video: App.state.feedActiveVideoId,
|
||||
playing: active ? Array.from(active.querySelectorAll('.feed-video'))
|
||||
.map(v => !v.paused && v.readyState >= 2) : null,
|
||||
scrollable: scroller ? scroller.scrollHeight > scroller.clientHeight + 1 : false,
|
||||
unique: (() => {
|
||||
const ids = Array.from(doc.querySelectorAll('.feed-pane')).map(p => p.dataset.videoId);
|
||||
return ids.length === new Set(ids).size;
|
||||
})(),
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 1000})
|
||||
page = context.new_page()
|
||||
open_reels(page)
|
||||
|
||||
c.ok("this browser offers document picture-in-picture",
|
||||
page.evaluate("() => App.feed.docPipSupported()"))
|
||||
|
||||
before = page.evaluate(PROBE, 'page')
|
||||
c.ok("the feed starts in the page", before["rooted_here"])
|
||||
c.ok("and is scrollable there", before["scrollable"])
|
||||
|
||||
print("\nthe picture-in-picture button opens a window")
|
||||
# A click, not an evaluate: requestWindow needs a user gesture, which is
|
||||
# exactly the reason the tab-switch path cannot use this API.
|
||||
with context.expect_page(timeout=15000) as caught:
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
pip = caught.value
|
||||
pip.wait_for_timeout(3000)
|
||||
|
||||
c.ok("the feed reports a window open", page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed left the page", not page.evaluate(
|
||||
"() => !!document.getElementById('feed-view')"))
|
||||
c.ok("the page underneath is usable again", page.evaluate(
|
||||
"() => document.body.style.overflow === 'auto'"))
|
||||
|
||||
moved = page.evaluate(PROBE, 'pip')
|
||||
c.ok("the feed is in the window", moved["rooted_here"], str(moved))
|
||||
c.ok("its slides came with it", moved["slides"] > 0, str(moved["slides"]))
|
||||
# The point of moving rather than rebuilding: the <video> elements are
|
||||
# the same ones, so nothing reloads and nothing stops.
|
||||
c.ok("playback survived the move", moved["playing"] and all(moved["playing"]),
|
||||
str(moved["playing"]))
|
||||
c.ok("it landed on the same video", moved["video"] == before["video"],
|
||||
f"{before['video']} -> {moved['video']}")
|
||||
|
||||
print("\nthe window scrolls the reel")
|
||||
c.ok("the window's feed is scrollable", moved["scrollable"], str(moved))
|
||||
pip.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
pip.wait_for_timeout(3500)
|
||||
scrolled = page.evaluate(PROBE, 'pip')
|
||||
c.ok("scrolling moved to the next step", scrolled["step"] == moved["step"] + 1,
|
||||
f"{moved['step']} -> {scrolled['step']}")
|
||||
c.ok("and to another video", scrolled["video"] != moved["video"],
|
||||
f"{moved['video']} -> {scrolled['video']}")
|
||||
c.ok("the new step is playing", scrolled["playing"] and all(scrolled["playing"]),
|
||||
str(scrolled["playing"]))
|
||||
|
||||
print("\nclosing the window brings the feed home")
|
||||
pip.close()
|
||||
page.wait_for_timeout(3500)
|
||||
back = page.evaluate(PROBE, 'page')
|
||||
c.ok("no window is open", not page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed is in the page again", back["rooted_here"], str(back))
|
||||
c.ok("reels is still open", page.evaluate("() => App.feed.isOpen()"))
|
||||
c.ok("the page is back in reels mode", page.evaluate(
|
||||
"() => document.body.classList.contains('feed-mode-open')"))
|
||||
# The window was scrolled while it was out; coming back must not undo it.
|
||||
c.ok("it kept where the window left off", back["video"] == scrolled["video"],
|
||||
f"{scrolled['video']} -> {back['video']}")
|
||||
c.ok("the feed still scrolls here", back["scrollable"], str(back))
|
||||
c.ok("no video was duplicated by the round trip", back["unique"])
|
||||
|
||||
print("\nleaving reels while the window is open")
|
||||
with context.expect_page(timeout=15000) as caught2:
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
pip2 = caught2.value
|
||||
pip2.wait_for_timeout(2500)
|
||||
page.evaluate("() => App.feed.close()")
|
||||
page.wait_for_timeout(2000)
|
||||
c.ok("closing reels closes the window", pip2.is_closed() or
|
||||
not page.evaluate("() => App.feed.docPipOpen()"))
|
||||
c.ok("the feed came back before it was torn down", page.evaluate(
|
||||
"() => !!document.getElementById('feed-view')"))
|
||||
c.ok("the page is scrollable again", page.evaluate(
|
||||
"() => document.body.style.overflow === 'auto'"))
|
||||
|
||||
context.close()
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
253
tests/smoke_grid.py
Normal file
253
tests/smoke_grid.py
Normal file
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grid smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_grid.py
|
||||
|
||||
The load-bearing check here is `content bleed`: every mounted card must render
|
||||
the video its own data-video-id names. Nothing enforced that before cards were
|
||||
recycled, because a card was thrown away the moment it left the window; once
|
||||
cards are reused, a release path that forgets to clear something shows one
|
||||
video's title, thumbnail or heart on another video's card.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
# Everything a mounted card renders, next to what its own id says it should.
|
||||
INSPECT = """() => {
|
||||
const byId = new Map();
|
||||
(App.state.loadedVideos || []).forEach((v) => byId.set(String(v.id), v));
|
||||
return Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
|
||||
const id = card.dataset.videoId;
|
||||
const v = byId.get(String(id)) || null;
|
||||
const img = card.querySelector('img');
|
||||
const dur = card.querySelector('.video-duration');
|
||||
const up = card.querySelector('.video-uploader');
|
||||
const fav = card.querySelector('.favorite-btn');
|
||||
return {
|
||||
id: id,
|
||||
known: !!v,
|
||||
title_shown: (card.querySelector('.video-title-text') || {}).textContent || '',
|
||||
title_expected: v ? (v.title || '') : null,
|
||||
// A thumbnail is served either straight from the provider or via
|
||||
// /api/image?url=<encoded>; compare on the provider URL either way.
|
||||
src_shown: (() => {
|
||||
const raw = img ? (img.getAttribute('src') || '') : '';
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const u = new URL(raw, location.href);
|
||||
return u.pathname === '/api/image'
|
||||
? (u.searchParams.get('url') || raw) : raw;
|
||||
} catch (e) { return raw; }
|
||||
})(),
|
||||
thumb_expected: v ? (v.thumb || '') : null,
|
||||
duration_shown: dur && !dur.hidden ? dur.textContent : '',
|
||||
duration_expected: v ? (App.videos.formatDuration(v.duration) || '') : null,
|
||||
uploader_shown: up && !up.hidden ? (up.dataset.uploader || up.textContent || '') : '',
|
||||
uploader_expected: v ? (v.uploader || '') : null,
|
||||
heart_shown: fav ? fav.classList.contains('is-favorite') : null,
|
||||
heart_expected: v ? App.favorites.has(v) : null,
|
||||
stale_loading: card.classList.contains('is-loading'),
|
||||
stale_pop: fav ? fav.classList.contains('just-favorited') : false,
|
||||
};
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def boot(page):
|
||||
"""Seed a known server/channel, then wait for the grid to fill.
|
||||
|
||||
Startup renders from the status cached in localStorage and refreshes it in
|
||||
the background, so the first visit has to wait for that round trip before
|
||||
any video is loaded.
|
||||
"""
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
# One reload, in case the status refresh or the listing request failed.
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def grow(page, want=80, tries=14):
|
||||
"""Load enough videos that the grid is taller than the mount window.
|
||||
|
||||
The virtualizer keeps everything within 1.2 viewports of the screen mounted,
|
||||
so a short list never unmounts anything and never exercises recycling.
|
||||
"""
|
||||
for _ in range(tries):
|
||||
if page.evaluate("() => App.state.loadedVideos.length") >= want:
|
||||
break
|
||||
page.evaluate("() => window.scrollTo(0, document.documentElement.scrollHeight)")
|
||||
page.wait_for_timeout(2000)
|
||||
return page.evaluate("() => App.state.loadedVideos.length")
|
||||
|
||||
|
||||
def scroll_around(page, downs=10):
|
||||
"""Churn the mount/unmount path: far down, then back to the top."""
|
||||
for _ in range(downs):
|
||||
page.evaluate("() => window.scrollBy(0, window.innerHeight * 1.5)")
|
||||
page.wait_for_timeout(500)
|
||||
page.wait_for_timeout(1200)
|
||||
page.evaluate("() => window.scrollTo(0, 0)")
|
||||
page.wait_for_timeout(1200)
|
||||
for _ in range(downs // 2):
|
||||
page.evaluate("() => window.scrollBy(0, window.innerHeight * 2.5)")
|
||||
page.wait_for_timeout(400)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
|
||||
def check_cards(c, cards, phase):
|
||||
print(f"\n{phase}: {len(cards)} cards mounted")
|
||||
c.ok(f"{phase}: cards are mounted", len(cards) > 0)
|
||||
c.ok(f"{phase}: every card's id is a loaded video",
|
||||
all(x["known"] for x in cards),
|
||||
str([x["id"] for x in cards if not x["known"]][:3]))
|
||||
|
||||
ids = [x["id"] for x in cards]
|
||||
c.ok(f"{phase}: no duplicate cards for one video", len(ids) == len(set(ids)))
|
||||
|
||||
for field in ("title", "duration", "uploader"):
|
||||
bad = [x for x in cards
|
||||
if x["known"] and (x[f"{field}_shown"] or "") != (x[f"{field}_expected"] or "")]
|
||||
c.ok(f"{phase}: {field} matches the card's own video", not bad,
|
||||
f"{len(bad)} mismatched, e.g. id={bad[0]['id']} "
|
||||
f"shown={bad[0][f'{field}_shown']!r} expected={bad[0][f'{field}_expected']!r}"
|
||||
if bad else "")
|
||||
|
||||
# The thumbnail may be served direct or through /api/image, so compare on
|
||||
# the underlying provider URL rather than the literal src.
|
||||
bad_src = [x for x in cards if x["known"] and x["src_shown"]
|
||||
and x["thumb_expected"] and x["thumb_expected"] not in x["src_shown"]
|
||||
and x["thumb_expected"].split("?")[0] not in x["src_shown"]]
|
||||
c.ok(f"{phase}: thumbnail belongs to the card's own video", not bad_src,
|
||||
f"{len(bad_src)} mismatched, e.g. id={bad_src[0]['id']}" if bad_src else "")
|
||||
|
||||
bad_heart = [x for x in cards if x["known"] and x["heart_shown"] != x["heart_expected"]]
|
||||
c.ok(f"{phase}: heart state matches the card's own video", not bad_heart,
|
||||
f"{len(bad_heart)} mismatched" if bad_heart else "")
|
||||
|
||||
stale = [x for x in cards if x["stale_loading"]]
|
||||
c.ok(f"{phase}: no card left in the loading state", not stale,
|
||||
f"{len(stale)} stuck" if stale else "")
|
||||
|
||||
# The favourite pop is animated away by animationend, which never fires on a
|
||||
# card released mid-animation -- so it can ride into the pool and replay on
|
||||
# whatever video the card is bound to next.
|
||||
popping = [x for x in cards if x["stale_pop"]]
|
||||
c.ok(f"{phase}: no card replaying the favourite animation", not popping,
|
||||
f"{len(popping)} popping, e.g. id={popping[0]['id']}" if popping else "")
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
# Lean launch flags: this runs alongside the app's own server, and a
|
||||
# default Chromium spikes hard enough at startup to get itself killed on
|
||||
# a constrained box.
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
boot(page)
|
||||
|
||||
check_cards(c, page.evaluate(INSPECT), "on first render")
|
||||
|
||||
# Informational: how many videos it took to outgrow the mount window
|
||||
# varies with viewport and page size. Whether that was *enough* is
|
||||
# asserted properly at the end, on the pool's hit rate.
|
||||
print(f"\ngrew the listing to {grow(page)} videos")
|
||||
|
||||
scroll_around(page)
|
||||
check_cards(c, page.evaluate(INSPECT), "after scrolling down and back")
|
||||
|
||||
# Favouriting must land on the clicked card and survive remounting.
|
||||
page.evaluate("""() => {
|
||||
const card = document.querySelector('#video-grid .video-card');
|
||||
card.querySelector('.favorite-btn').click();
|
||||
}""")
|
||||
page.wait_for_timeout(800)
|
||||
favourited = page.evaluate("() => App.favorites.getAll().map(f => f.key)")
|
||||
c.ok("favouriting stores exactly one entry", len(favourited) == 1, str(favourited))
|
||||
|
||||
scroll_around(page, downs=4)
|
||||
cards = page.evaluate(INSPECT)
|
||||
check_cards(c, cards, "after favouriting and scrolling")
|
||||
|
||||
# The menu still opens on a card that has been through the cycle.
|
||||
opened = page.evaluate("""() => {
|
||||
const card = document.querySelector('#video-grid .video-card');
|
||||
card.querySelector('.video-menu-btn').click();
|
||||
return card.querySelector('.video-menu').classList.contains('open');
|
||||
}""")
|
||||
c.ok("the card menu opens after recycling", opened)
|
||||
|
||||
# Tag clicks read the button's own text now, not a data attribute.
|
||||
searched = page.evaluate("""() => {
|
||||
const tag = document.querySelector('#video-grid .video-card .video-tag');
|
||||
if (!tag) return 'no-tags';
|
||||
const label = tag.textContent;
|
||||
tag.click();
|
||||
return document.getElementById('search-input').value === label ? 'ok' : 'mismatch';
|
||||
}""")
|
||||
c.ok("clicking a tag searches for it", searched in ("ok", "no-tags"), searched)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
stats = page.evaluate("""
|
||||
() => (App.virtualGrid.stats && App.virtualGrid.stats()) || null
|
||||
""")
|
||||
if stats:
|
||||
built = stats.get("built", 0)
|
||||
recycled = stats.get("recycled", 0)
|
||||
readymade = stats.get("prepared", 0)
|
||||
total = (built + recycled + readymade) or 1
|
||||
reused = 100 * (recycled + readymade) // total
|
||||
print(f"\nmounts: {total} -- {built} built, {recycled} recycled, "
|
||||
f"{readymade} prepared ahead; {stats.get('pooled', 0)} idle in pool, "
|
||||
f"{stats.get('readied', 0)} still readied")
|
||||
# If almost everything is still built per mount, none of the checks
|
||||
# above actually exercised a reused card.
|
||||
c.ok("cards are reused rather than rebuilt", reused >= 50, f"{reused}% reused")
|
||||
c.ok("some cards were prepared before they were needed", readymade > 0,
|
||||
"prepare-ahead never served a mount")
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
170
tests/smoke_ios_pip.py
Normal file
170
tests/smoke_ios_pip.py
Normal file
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Picture-in-picture on iOS, where the standard API does not exist.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_ios_pip.py
|
||||
|
||||
Safari on iPhone and iPad never implemented requestPictureInPicture. It has
|
||||
picture-in-picture -- it just reaches it through WebKit's older
|
||||
presentation-mode switch, and document.pictureInPictureEnabled is undefined,
|
||||
so every capability check answered "no" and the button was hidden on the one
|
||||
platform where people most want it.
|
||||
|
||||
There is no iPhone here, so the browser is reshaped to have iOS's API surface
|
||||
instead: the standard entry points are deleted and WebKit's are installed. That
|
||||
is enough to test what actually broke, because what broke was which API the
|
||||
code reaches for -- not what the browser does once it is called.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
# Runs before any page script, so the app only ever sees the iOS shape.
|
||||
AS_IOS = """(() => {
|
||||
delete Document.prototype.pictureInPictureEnabled;
|
||||
delete Document.prototype.pictureInPictureElement;
|
||||
delete Document.prototype.exitPictureInPicture;
|
||||
delete HTMLVideoElement.prototype.requestPictureInPicture;
|
||||
delete HTMLVideoElement.prototype.disablePictureInPicture;
|
||||
delete window.documentPictureInPicture;
|
||||
|
||||
window.__pipCalls = [];
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'webkitPresentationMode', {
|
||||
configurable: true,
|
||||
get() { return this.__mode || 'inline'; },
|
||||
});
|
||||
HTMLVideoElement.prototype.webkitSupportsPresentationMode = function() { return true; };
|
||||
HTMLVideoElement.prototype.webkitSetPresentationMode = function(mode) {
|
||||
window.__pipCalls.push(mode);
|
||||
this.__mode = mode;
|
||||
// WebKit's event, which notably does not bubble.
|
||||
this.dispatchEvent(new Event('webkitpresentationmodechanged'));
|
||||
};
|
||||
})();"""
|
||||
|
||||
PINNED = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
return {
|
||||
calls: window.__pipCalls.slice(),
|
||||
pinned: !!(slide && App.feed.pipPinned(slide)),
|
||||
modes: Array.from(document.querySelectorAll('.feed-video'))
|
||||
.filter(v => v.webkitPresentationMode === 'picture-in-picture').length,
|
||||
video: App.state.feedActiveVideoId,
|
||||
paneVideo: slide ? slide.querySelector('.feed-pane').dataset.videoId : null,
|
||||
};
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 430, "height": 930})
|
||||
page.add_init_script(AS_IOS)
|
||||
open_reels(page)
|
||||
|
||||
print("\nthe capability check")
|
||||
c.ok("the standard API really is gone", page.evaluate(
|
||||
"() => !document.pictureInPictureEnabled && !window.documentPictureInPicture"))
|
||||
c.ok("picture-in-picture is still reported as available",
|
||||
page.evaluate("() => App.customPlayer.supportsPiP()"))
|
||||
c.ok("the button is not hidden", page.evaluate(
|
||||
"""() => { const b = document.querySelector('.feed-slide.is-active .feed-pip-btn');
|
||||
return !!b && !b.hidden; }"""))
|
||||
|
||||
print("\nthe button asks WebKit for it")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
entered = page.evaluate(PINNED)
|
||||
c.ok("it called webkitSetPresentationMode",
|
||||
entered["calls"] == ["picture-in-picture"], str(entered["calls"]))
|
||||
c.ok("exactly one video went to the window", entered["modes"] == 1, str(entered["modes"]))
|
||||
# WebKit's event does not bubble, so the feed's document-level listener
|
||||
# only hears about this if the adapter re-fires it.
|
||||
c.ok("the feed noticed and pinned the pane", entered["pinned"], str(entered))
|
||||
|
||||
print("\nmoving through the reel from the window")
|
||||
page.evaluate("() => App.feed.pipStep(1)")
|
||||
page.wait_for_timeout(3000)
|
||||
stepped = page.evaluate(PINNED)
|
||||
c.ok("the pinned pane moved to another video",
|
||||
stepped["paneVideo"] != entered["paneVideo"],
|
||||
f"{entered['paneVideo']} -> {stepped['paneVideo']}")
|
||||
c.ok("it is still the video in the window", stepped["pinned"], str(stepped))
|
||||
c.ok("and still only one", stepped["modes"] == 1, str(stepped["modes"]))
|
||||
|
||||
print("\nleaving the window")
|
||||
# What iOS does when the reader taps the window's close control.
|
||||
page.evaluate("""() => document.querySelectorAll('.feed-video').forEach((v) => {
|
||||
if (v.webkitPresentationMode === 'picture-in-picture') v.webkitSetPresentationMode('inline');
|
||||
})""")
|
||||
page.wait_for_timeout(3000)
|
||||
left = page.evaluate(PINNED)
|
||||
c.ok("the pin was released", not left["pinned"], str(left))
|
||||
c.ok("nothing is left in a window", left["modes"] == 0, str(left["modes"]))
|
||||
c.ok("the feed landed on the video the window ended on",
|
||||
left["video"] == stepped["paneVideo"],
|
||||
f"{stepped['paneVideo']} -> {left['video']}")
|
||||
c.ok("no video is shown twice after the rebuild", page.evaluate(
|
||||
"""() => { const ids = Array.from(document.querySelectorAll('.feed-pane'))
|
||||
.map(p => p.dataset.videoId);
|
||||
return ids.length === new Set(ids).size; }"""))
|
||||
|
||||
print("\nthe button toggles back off")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pip-btn")
|
||||
page.wait_for_timeout(1500)
|
||||
toggled = page.evaluate(PINNED)
|
||||
c.ok("the second press asked to go back inline",
|
||||
toggled["calls"][-1] == "inline", str(toggled["calls"]))
|
||||
c.ok("nothing is left in a window", toggled["modes"] == 0, str(toggled["modes"]))
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
176
tests/smoke_reels.py
Executable file
176
tests/smoke_reels.py
Executable file
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reels split-panel smoke tests.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_reels.py
|
||||
|
||||
The load-bearing check is the preload guarantee: every panel must have its
|
||||
next video buffered before the reader swipes. With locked scrolling that is
|
||||
the same panel position in the next step, so it holds only while the next
|
||||
step is both built and preloaded for all of its panes -- which in turn rests
|
||||
on the floor of one step in windowBounds() and in setActive()'s preloadAhead.
|
||||
Both shrink as panes are added, and without the floor a wide split would
|
||||
leave panels with nothing to swipe to.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}"""
|
||||
|
||||
LAYOUT = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
if (!slide) return null;
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
const bounds = slide.getBoundingClientRect();
|
||||
return {
|
||||
count: panes.length,
|
||||
reported: App.feed.paneCount(),
|
||||
videos: panes.map(p => p.dataset.videoId),
|
||||
// Controls positioned for a full viewport end up outside a short pane,
|
||||
// clipped by its overflow and unreachable.
|
||||
escaping: panes.reduce((bad, p, i) => {
|
||||
const pr = p.getBoundingClientRect();
|
||||
['.feed-fav-btn', '.feed-pip-btn', '.feed-format-btn', '.feed-pane-tools']
|
||||
.forEach((sel) => {
|
||||
const el = p.querySelector(sel);
|
||||
if (!el || el.hidden) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (r.top < pr.top - 1 || r.bottom > pr.bottom + 1 ||
|
||||
r.left < pr.left - 1 || r.right > pr.right + 1) bad.push(i + sel);
|
||||
});
|
||||
return bad;
|
||||
}, []),
|
||||
within_slide: panes.every(p => {
|
||||
const r = p.getBoundingClientRect();
|
||||
return r.top >= bounds.top - 1 && r.bottom <= bounds.bottom + 1;
|
||||
}),
|
||||
};
|
||||
}"""
|
||||
|
||||
PRELOAD = """() => {
|
||||
const active = App.state.feedActiveIndex;
|
||||
const rows = {};
|
||||
document.querySelectorAll('.feed-slide').forEach((slide) => {
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
rows[Number(slide.dataset.step) - active] = panes.map((p) => {
|
||||
const v = p.querySelector('.feed-video');
|
||||
return !!(v && (v.getAttribute('src') || v._hlsPlayer));
|
||||
});
|
||||
});
|
||||
return { per: App.feed.paneCount(), next: rows[1] || null, current: rows[0] || null };
|
||||
}"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{'PASS' if condition else 'FAIL'}] {label}"
|
||||
+ (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
try:
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
except Exception:
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(4000)
|
||||
page.evaluate("() => App.feed.toggle()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
|
||||
def split(page, selector):
|
||||
page.click(".feed-slide.is-active " + selector)
|
||||
page.wait_for_timeout(4000)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
"--renderer-process-limit=1",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
open_reels(page)
|
||||
|
||||
print("\nsingle panel")
|
||||
one = page.evaluate(LAYOUT)
|
||||
c.ok("reels opens with one panel", one and one["count"] == 1, str(one))
|
||||
|
||||
print("\nsplit right, then split the new panel below")
|
||||
split(page, ".feed-pane .feed-pane-split-right")
|
||||
split(page, ".feed-pane:last-of-type .feed-pane-split-down")
|
||||
three = page.evaluate(LAYOUT)
|
||||
c.ok("three panels after two splits", three["count"] == 3, str(three["count"]))
|
||||
c.ok("paneCount agrees with the DOM", three["reported"] == three["count"])
|
||||
c.ok("every panel shows a different video",
|
||||
len(set(three["videos"])) == len(three["videos"]), str(three["videos"]))
|
||||
c.ok("panels fit inside the slide", three["within_slide"])
|
||||
c.ok("no control escapes its panel", not three["escaping"], str(three["escaping"]))
|
||||
|
||||
print("\npreload")
|
||||
pre = page.evaluate(PRELOAD)
|
||||
c.ok("the next step exists", pre["next"] is not None)
|
||||
c.ok("every panel of the current step is loaded", pre["current"] and all(pre["current"]),
|
||||
str(pre["current"]))
|
||||
# The point of the exercise: nobody should swipe into an empty panel.
|
||||
c.ok("every panel has its next video preloaded",
|
||||
pre["next"] is not None and all(pre["next"]) and len(pre["next"]) == pre["per"],
|
||||
str(pre["next"]))
|
||||
|
||||
print("\none swipe advances every panel")
|
||||
before = page.evaluate(LAYOUT)["videos"]
|
||||
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
page.wait_for_timeout(3500)
|
||||
after = page.evaluate(LAYOUT)["videos"]
|
||||
c.ok("all panels moved on", all(v not in before for v in after if v),
|
||||
f"{before} -> {after}")
|
||||
c.ok("still preloaded after the swipe",
|
||||
all(page.evaluate(PRELOAD)["next"] or [False]))
|
||||
|
||||
print("\nper-panel audio")
|
||||
page.click(".feed-slide.is-active .feed-pane:first-of-type .feed-pane-mute")
|
||||
page.wait_for_timeout(1200)
|
||||
page.evaluate("() => App.feed.renderSlides()") # re-activate the step
|
||||
page.wait_for_timeout(1500)
|
||||
muted = page.evaluate("""() => Array.from(
|
||||
document.querySelectorAll('.feed-slide.is-active .feed-pane .feed-video')
|
||||
).map(v => v.muted)""")
|
||||
c.ok("only the unmuted panel has sound", muted and muted[0] is False
|
||||
and all(muted[1:]), str(muted))
|
||||
|
||||
print("\nclose a panel")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pane-close")
|
||||
page.wait_for_timeout(3500)
|
||||
closed = page.evaluate(LAYOUT)
|
||||
c.ok("closing collapses the split", closed["count"] == 2, str(closed["count"]))
|
||||
c.ok("survivors still fit the slide", closed["within_slide"])
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
175
tests/smoke_thumbnails.py
Normal file
175
tests/smoke_thumbnails.py
Normal file
@@ -0,0 +1,175 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thumbnail smoke tests: junk URLs, and images that fail once.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_thumbnails.py
|
||||
|
||||
The listing and the image hosts are both served by this script rather than by a
|
||||
provider, because what's under test is what the client does with awkward data:
|
||||
|
||||
* a `thumb` that isn't a URL at all -- sxyprn's "latest" listing sends items
|
||||
whose thumb is the bare string "https:". Resolved against the page that is
|
||||
*our own* address, so the card used to race our own HTML as if it were a
|
||||
picture, pin our origin to the proxy for the rest of the session, and ask
|
||||
/api/image to fetch "https:" (a 400, every time).
|
||||
|
||||
* a thumbnail whose first request fails. One blip used to mean an empty box
|
||||
for as long as the card stayed mounted.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
CDN = "https://cdn.example-thumbs.test"
|
||||
|
||||
# 1x1 transparent PNG.
|
||||
PIXEL = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==")
|
||||
|
||||
SEED = """([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
localStorage.setItem('favorites', JSON.stringify([]));
|
||||
}"""
|
||||
|
||||
STATE = """() => Array.from(document.querySelectorAll('#video-grid .video-card')).map((card) => {
|
||||
const img = card.querySelector('img');
|
||||
return {
|
||||
id: card.dataset.videoId,
|
||||
src: img ? img.getAttribute('src') || '' : '',
|
||||
loaded: img ? img.naturalWidth > 0 : false,
|
||||
};
|
||||
})"""
|
||||
|
||||
|
||||
class Checks:
|
||||
def __init__(self):
|
||||
self.failed = 0
|
||||
|
||||
def ok(self, label, condition, detail=""):
|
||||
mark = "PASS" if condition else "FAIL"
|
||||
if not condition:
|
||||
self.failed += 1
|
||||
print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
|
||||
|
||||
|
||||
def listing():
|
||||
"""Twelve ordinary items, one with the junk thumb, one that fails once."""
|
||||
items = []
|
||||
for i in range(12):
|
||||
items.append({
|
||||
"id": f"test:{i}",
|
||||
"title": f"Video {i}",
|
||||
"url": f"{CDN}/watch/{i}",
|
||||
"channel": "test",
|
||||
"duration": 60 + i,
|
||||
"thumb": f"{CDN}/thumb/{i}.png",
|
||||
"tags": [],
|
||||
})
|
||||
items[3]["thumb"] = "https:" # what sxyprn's "latest" actually sends
|
||||
items[7]["thumb"] = f"{CDN}/flaky.png"
|
||||
return {"items": items, "pageInfo": {"hasNextPage": False}}
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
image_proxy_calls = []
|
||||
flaky_hits = {"direct": 0, "proxy": 0}
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
|
||||
def serve_listing(route):
|
||||
route.fulfill(status=200, content_type="application/json",
|
||||
body=json.dumps(listing()))
|
||||
|
||||
def serve_proxy(route):
|
||||
# Only the thumbnails matter here; record what the client asked us
|
||||
# to fetch on its behalf, and hand back the picture.
|
||||
image_proxy_calls.append(route.request.url)
|
||||
if "flaky.png" in route.request.url:
|
||||
flaky_hits["proxy"] += 1
|
||||
# The flaky picture is refused on *both* routes the first time
|
||||
# round, which is what used to leave the card empty for good.
|
||||
if flaky_hits["proxy"] == 1:
|
||||
route.fulfill(status=502, content_type="text/plain", body="nope")
|
||||
return
|
||||
route.fulfill(status=200, content_type="image/png", body=PIXEL)
|
||||
|
||||
def serve_cdn(route):
|
||||
if route.request.url.endswith("/flaky.png"):
|
||||
flaky_hits["direct"] += 1
|
||||
if flaky_hits["direct"] == 1:
|
||||
route.abort("connectionfailed")
|
||||
return
|
||||
route.fulfill(status=200, content_type="image/png", body=PIXEL)
|
||||
|
||||
page.route("**/api/videos", serve_listing)
|
||||
page.route("**/api/image*", serve_proxy)
|
||||
page.route(f"{CDN}/**", serve_cdn)
|
||||
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate(SEED, [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=60000)
|
||||
# Long enough for the host race (2.5s of patience) and the retry ladder
|
||||
# (~900ms for its second step) to have run their course.
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
cards = page.evaluate(STATE)
|
||||
by_id = {card["id"]: card for card in cards}
|
||||
|
||||
print("\na thumb that isn't a URL")
|
||||
junk = by_id.get("test:3")
|
||||
c.ok("the card is mounted", junk is not None)
|
||||
if junk:
|
||||
c.ok("it asks for nothing at all", junk["src"] == "",
|
||||
f"src={junk['src']!r}")
|
||||
c.ok("and nothing is sent to the image proxy for it",
|
||||
not [u for u in image_proxy_calls if "https%3A&" in u or u.endswith("url=https%3A")],
|
||||
str([u for u in image_proxy_calls if "https%3A" in u][:2]))
|
||||
|
||||
print("\nthe rest of the page is unaffected by it")
|
||||
others = [card for card in cards if card["id"] != "test:3"]
|
||||
c.ok("every other card shows its picture",
|
||||
all(card["loaded"] for card in others),
|
||||
str([card["id"] for card in others if not card["loaded"]]))
|
||||
# The junk URL used to resolve to our own origin, whose race then failed
|
||||
# and pinned it to the proxy -- for everything, for the whole session.
|
||||
c.ok("our own origin is not pinned to the proxy",
|
||||
page.evaluate("() => App.videos.thumbnailUrl(location.origin + '/x.png')")
|
||||
== page.evaluate("() => location.origin + '/x.png'"))
|
||||
|
||||
print("\na thumbnail refused on both routes, once")
|
||||
flaky = by_id.get("test:7")
|
||||
c.ok("the card is mounted", flaky is not None)
|
||||
c.ok("both routes were refused once",
|
||||
flaky_hits["direct"] >= 1 and flaky_hits["proxy"] >= 1,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
c.ok("and it was asked for again after that",
|
||||
flaky_hits["direct"] + flaky_hits["proxy"] > 2,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
if flaky:
|
||||
c.ok("so the card ends up showing a picture", flaky["loaded"],
|
||||
f"src={flaky['src']!r}")
|
||||
c.ok("and a dead thumbnail stops being asked for",
|
||||
flaky_hits["direct"] + flaky_hits["proxy"] <= 4,
|
||||
f"direct={flaky_hits['direct']} proxy={flaky_hits['proxy']}")
|
||||
|
||||
browser.close()
|
||||
|
||||
print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
|
||||
return 1 if c.failed else 0
|
||||
|
||||
|
||||
sys.exit(main())
|
||||
159
tests/unit_formats.js
Executable file
159
tests/unit_formats.js
Executable file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
/* Format selection, tested without a browser.
|
||||
*
|
||||
* node tests/unit_formats.js
|
||||
*
|
||||
* Picking a rendition is pure: a list of formats in, one URL out. That makes it
|
||||
* the one part of playback that can be checked in a second, with no server, no
|
||||
* Chromium and no network -- which matters, because the height cap a split
|
||||
* reels panel applies is the difference between decoding four 1080p streams and
|
||||
* four 480p ones.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
|
||||
// Enough of a browser for videos.js to finish loading. It builds a few
|
||||
// IntersectionObservers and reads matchMedia at module scope; nothing below
|
||||
// touches the DOM.
|
||||
const noop = () => {};
|
||||
const element = () => ({
|
||||
style: { setProperty: noop, removeProperty: noop },
|
||||
classList: { add: noop, remove: noop, toggle: noop, contains: () => false },
|
||||
dataset: {},
|
||||
querySelector: () => null,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
removeEventListener: noop,
|
||||
appendChild: noop,
|
||||
removeChild: noop,
|
||||
remove: noop,
|
||||
getBoundingClientRect: () => ({ width: 0, height: 0, top: 0, bottom: 0, left: 0, right: 0 }),
|
||||
setAttribute: noop,
|
||||
removeAttribute: noop,
|
||||
getAttribute: () => null,
|
||||
cloneNode: element,
|
||||
content: { firstElementChild: { cloneNode: element } },
|
||||
children: [],
|
||||
childElementCount: 0,
|
||||
});
|
||||
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
URL,
|
||||
Image: function () { return element(); },
|
||||
IntersectionObserver: function () {
|
||||
return { observe: noop, unobserve: noop, disconnect: noop };
|
||||
},
|
||||
requestAnimationFrame: noop,
|
||||
requestIdleCallback: noop,
|
||||
performance: { now: () => 0 },
|
||||
localStorage: { getItem: () => null, setItem: noop, removeItem: noop },
|
||||
};
|
||||
sandbox.addEventListener = noop;
|
||||
sandbox.removeEventListener = noop;
|
||||
sandbox.window = sandbox;
|
||||
sandbox.self = sandbox;
|
||||
sandbox.globalThis = sandbox;
|
||||
sandbox.document = {
|
||||
getElementById: () => null,
|
||||
createElement: element,
|
||||
querySelectorAll: () => [],
|
||||
addEventListener: noop,
|
||||
documentElement: element(),
|
||||
body: element(),
|
||||
head: element(),
|
||||
};
|
||||
sandbox.window.matchMedia = () => ({ matches: false, addEventListener: noop });
|
||||
sandbox.window.location = { href: 'http://localhost/' };
|
||||
|
||||
const context = vm.createContext(sandbox);
|
||||
const load = (file) => vm.runInContext(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'frontend', 'js', file), 'utf8'), context, file);
|
||||
|
||||
// videos.js reaches for these siblings when a card is built; none of the
|
||||
// functions under test do.
|
||||
sandbox.App = { state: {}, constants: {}, favorites: { getKey: () => null, has: () => false,
|
||||
setButtonState: noop }, storage: { getPreferredQuality: () => 'auto' } };
|
||||
load('videos.js');
|
||||
|
||||
const { rankFormats, resolveStreamSource, resolveStreamSources } = sandbox.App.videos;
|
||||
|
||||
let failed = 0;
|
||||
const ok = (label, cond, detail) => {
|
||||
if (!cond) failed++;
|
||||
console.log(` [${cond ? 'PASS' : 'FAIL'}] ${label}` + (!cond && detail ? ` -- ${detail}` : ''));
|
||||
};
|
||||
|
||||
const formats = [
|
||||
{ url: 'u240', height: 240, vcodec: 'avc1' },
|
||||
{ url: 'u480', height: 480, vcodec: 'avc1' },
|
||||
{ url: 'u720', height: 720, vcodec: 'avc1' },
|
||||
{ url: 'u1080', height: 1080, vcodec: 'avc1' },
|
||||
];
|
||||
const video = { id: 'v1', url: 'https://example.com/watch', meta: { formats: formats } };
|
||||
const heightOf = (src) => (formats.find((f) => f.url === src.url) || {}).height;
|
||||
|
||||
console.log('\nranking');
|
||||
ok('no ceiling takes the best', rankFormats(formats, null)[0].height === 1080);
|
||||
ok('a ceiling takes the best at or below it', rankFormats(formats, 720)[0].height === 720);
|
||||
ok('an exact ceiling is allowed', rankFormats(formats, 480)[0].height === 480);
|
||||
ok('below every rendition still returns one', rankFormats(formats, 100)[0].height === 240,
|
||||
String(rankFormats(formats, 100)[0].height));
|
||||
ok('everything stays reachable as fallback', rankFormats(formats, 480).length === formats.length);
|
||||
|
||||
console.log('\nthe cap a split panel applies');
|
||||
sandbox.App.storage.getPreferredQuality = () => 'auto';
|
||||
ok('uncapped panel gets the best', heightOf(resolveStreamSource(video)) === 1080);
|
||||
ok('a quarter-screen panel gets a quarter-screen rendition',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
|
||||
ok('the cap survives into the fallback order',
|
||||
heightOf(resolveStreamSources(video, { maxHeight: 480 })[0]) === 480);
|
||||
|
||||
console.log('\nthe cap and the quality preference are both ceilings');
|
||||
sandbox.App.storage.getPreferredQuality = () => '720';
|
||||
ok('preference alone caps at 720', heightOf(resolveStreamSource(video)) === 720);
|
||||
ok('the tighter of the two wins (panel)',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 480 })) === 480);
|
||||
sandbox.App.storage.getPreferredQuality = () => '480';
|
||||
ok('the tighter of the two wins (preference)',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 720 })) === 480);
|
||||
ok('a cap never raises the preference',
|
||||
heightOf(resolveStreamSource(video, { maxHeight: 2160 })) === 480);
|
||||
|
||||
console.log('\nranking by what it costs to decode');
|
||||
// Same picture, four ways of arriving at it.
|
||||
const mixed = [
|
||||
{ url: 'hls720', height: 720, vcodec: 'avc1', protocol: 'm3u8_native', fps: 30 },
|
||||
{ url: 'av1720', height: 720, vcodec: 'av01.0.05M.08', protocol: 'https', ext: 'mp4', fps: 30 },
|
||||
{ url: 'avc720', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 30, tbr: 900 },
|
||||
{ url: 'avc720p60', height: 720, vcodec: 'avc1', protocol: 'https', ext: 'mp4', fps: 60, tbr: 2000 },
|
||||
];
|
||||
const mixedVideo = { id: 'v2', url: 'https://example.com/w2', meta: { formats: mixed } };
|
||||
sandbox.App.storage.getPreferredQuality = () => 'auto';
|
||||
|
||||
const best = (opts) => resolveStreamSource(mixedVideo, opts).url;
|
||||
ok('without the flag, bitrate still wins', best({}) === 'avc720p60', best({}));
|
||||
ok('cheapest avoids HLS demuxing in JS', best({ cheapest: true }) !== 'hls720');
|
||||
ok('cheapest avoids software-decoded AV1', best({ cheapest: true }) !== 'av1720');
|
||||
ok('cheapest avoids 60fps', best({ cheapest: true }) !== 'avc720p60');
|
||||
ok('cheapest picks progressive H.264 at 30fps',
|
||||
best({ cheapest: true }) === 'avc720', best({ cheapest: true }));
|
||||
|
||||
// Cheapness must not override the size ceiling, or a split panel would get a
|
||||
// bigger picture than it can afford just because it is cheap per pixel.
|
||||
const tall = [
|
||||
{ url: 'hls480', height: 480, vcodec: 'avc1', protocol: 'm3u8_native' },
|
||||
{ url: 'mp4_1080', height: 1080, vcodec: 'avc1', protocol: 'https', ext: 'mp4' },
|
||||
];
|
||||
const tallVideo = { id: 'v3', url: 'https://example.com/w3', meta: { formats: tall } };
|
||||
ok('the height ceiling still comes first',
|
||||
resolveStreamSource(tallVideo, { maxHeight: 480, cheapest: true }).url === 'hls480');
|
||||
|
||||
ok('every format stays reachable as fallback',
|
||||
rankFormats(mixed, null, { cheapest: true }).length === mixed.length);
|
||||
|
||||
console.log(`\n${failed ? 'FAILED' : 'OK'}: ${failed} check(s) failed`);
|
||||
process.exit(failed ? 1 : 0);
|
||||
Reference in New Issue
Block a user