Compare commits
17 Commits
1dbac33359
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a51d9ee2 | ||
|
|
b4bc90372d | ||
|
|
4b03789ef2 | ||
|
|
051dae98cb | ||
|
|
a795442634 | ||
|
|
b478c51551 | ||
|
|
191c81e55e | ||
|
|
2abdc31f56 | ||
|
|
76a078b9b5 | ||
|
|
451bf0f983 | ||
|
|
764e3416a3 | ||
|
|
f0df53365d | ||
|
|
6e5ab68a94 | ||
|
|
e603111d70 | ||
|
|
7624ca559a | ||
|
|
49992c1db0 | ||
|
|
b4031b5d0e |
@@ -450,6 +450,104 @@ def resolve_video():
|
||||
|
||||
return jsonify(view_of(info))
|
||||
|
||||
# The picture a page says it has, in the order worth trusting: the card the
|
||||
# page wants shared, then the one it declares to search engines, then the still
|
||||
# its own player shows before playing.
|
||||
_POSTER_META_RE = re.compile(
|
||||
r'''<meta[^>]+(?:property|name|itemprop)\s*=\s*["']?'''
|
||||
r'''(og:image(?::url)?|twitter:image(?::src)?|thumbnailUrl)["']?[^>]*>''', re.I)
|
||||
_POSTER_CONTENT_RE = re.compile(r'''content\s*=\s*["']([^"']+)["']''', re.I)
|
||||
_POSTER_VIDEO_RE = re.compile(r'''<video[^>]+poster\s*=\s*["']([^"']+)["']''', re.I)
|
||||
# Everything worth having is in the head; a post page's comment section is not.
|
||||
_POSTER_SCAN_BYTES = 256 * 1024
|
||||
POSTER_CACHE_TTL = 600
|
||||
_poster_cache = {}
|
||||
_poster_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _poster_from_html(html, base_url):
|
||||
"""The first usable image URL declared by `html`, absolute, or None."""
|
||||
candidates = []
|
||||
for match in _POSTER_META_RE.finditer(html):
|
||||
content = _POSTER_CONTENT_RE.search(match.group(0))
|
||||
if content:
|
||||
candidates.append((match.group(1).lower(), content.group(1)))
|
||||
ranked = []
|
||||
for key in ('og:image', 'og:image:url', 'twitter:image', 'twitter:image:src', 'thumbnailurl'):
|
||||
ranked += [url for name, url in candidates if name == key]
|
||||
video_poster = _POSTER_VIDEO_RE.search(html)
|
||||
if video_poster:
|
||||
ranked.append(video_poster.group(1))
|
||||
for url in ranked:
|
||||
absolute = urljoin(base_url, url.strip())
|
||||
parsed = urllib.parse.urlparse(absolute)
|
||||
if parsed.scheme in ('http', 'https') and parsed.netloc:
|
||||
return absolute
|
||||
return None
|
||||
|
||||
|
||||
@app.route('/api/poster', methods=['GET'])
|
||||
def page_poster():
|
||||
"""Ask a video's own page what picture it shows.
|
||||
|
||||
A listing's thumbnail can be dead on arrival. sxyprn hands out a still it
|
||||
derives from the video (`.../vid/<token>/.../full.jpg`) which, for a post
|
||||
made minutes ago, the CDN has not generated -- while the post page itself
|
||||
shows one that works (`.../img/<other token>/.../0.webp`). Nothing on the
|
||||
client can guess the second from the first: each path carries its own
|
||||
signature. So when a thumbnail has failed every way we know to ask for it,
|
||||
the page it came from gets asked what it shows.
|
||||
|
||||
Cheap to be wrong about and expensive to repeat, so answers -- including
|
||||
"nothing" -- are cached for a few minutes."""
|
||||
page_url = request.args.get('url')
|
||||
if not page_url:
|
||||
return jsonify({"error": "No URL provided"}), 400
|
||||
parsed = urllib.parse.urlparse(page_url)
|
||||
if parsed.scheme not in ('http', 'https') or not parsed.netloc:
|
||||
return jsonify({"error": "Invalid target URL"}), 400
|
||||
|
||||
now = time.time()
|
||||
with _poster_cache_lock:
|
||||
for key in [k for k, v in _poster_cache.items() if v[0] <= now]:
|
||||
_poster_cache.pop(key, None)
|
||||
hit = _poster_cache.get(page_url)
|
||||
if hit:
|
||||
return jsonify({"thumb": hit[1]})
|
||||
|
||||
thumb = None
|
||||
sess = _borrow_session()
|
||||
try:
|
||||
# Streamed, and only the head of it: a page URL that turns out to
|
||||
# redirect to the video itself (the Hot Tub proxy does exactly that for
|
||||
# some channels) must cost one buffer, not a whole download.
|
||||
resp = sess.get(page_url, headers={'Referer': page_url}, timeout=15,
|
||||
allow_redirects=True, stream=True)
|
||||
try:
|
||||
content_type = (resp.headers.get('Content-Type') or '').lower()
|
||||
if resp.status_code < 400 and ('html' in content_type or not content_type):
|
||||
head = b''
|
||||
for chunk in resp.iter_content(32 * 1024):
|
||||
head += chunk
|
||||
if len(head) >= _POSTER_SCAN_BYTES:
|
||||
break
|
||||
thumb = _poster_from_html(
|
||||
head.decode(resp.encoding or 'utf-8', errors='replace'),
|
||||
resp.url or page_url)
|
||||
finally:
|
||||
resp.close()
|
||||
except Exception:
|
||||
_discard_session(sess)
|
||||
sess = None
|
||||
finally:
|
||||
if sess is not None:
|
||||
_return_session(sess)
|
||||
|
||||
with _poster_cache_lock:
|
||||
_poster_cache[page_url] = (now + POSTER_CACHE_TTL, thumb)
|
||||
return jsonify({"thumb": thumb})
|
||||
|
||||
|
||||
@app.route('/api/image', methods=['GET', 'HEAD'])
|
||||
def image_proxy():
|
||||
image_url = request.args.get('url')
|
||||
|
||||
@@ -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;
|
||||
@@ -1041,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);
|
||||
@@ -1312,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;
|
||||
}
|
||||
|
||||
@@ -1381,18 +1393,40 @@ body.theme-light .favorite-btn {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
margin: 0 0 16px 0;
|
||||
/* Clear of the copy/close buttons parked in the corner. */
|
||||
padding-right: 140px;
|
||||
}
|
||||
|
||||
.info-close {
|
||||
/* Copy and close sit together in the card's top corner, above the title. */
|
||||
.info-actions {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info-copy {
|
||||
width: auto;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info-copy.is-copied {
|
||||
border-color: var(--border-hover);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.info-close {
|
||||
border: none;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1494,6 +1528,22 @@ body.theme-light .favorite-btn {
|
||||
animation: cp-open 0.22s ease;
|
||||
}
|
||||
|
||||
/* Loading before it is shown: laid out, so the media element loads as it
|
||||
normally would (a display:none video doesn't, on iOS), but invisible and
|
||||
transparent to the pointer, so the grid underneath stays the page the
|
||||
viewer is on. */
|
||||
.custom-player.is-preloading,
|
||||
/* The HUD's own children opt back into pointer events, so they have to be
|
||||
told as well -- an invisible close button must not eat a card's click. */
|
||||
.custom-player.is-preloading * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.custom-player.is-preloading {
|
||||
display: block;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes cp-open {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -2146,6 +2196,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%;
|
||||
@@ -2153,6 +2206,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;
|
||||
@@ -2160,6 +2241,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;
|
||||
@@ -2173,7 +2293,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;
|
||||
}
|
||||
|
||||
@@ -2298,6 +2418,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;
|
||||
@@ -2366,13 +2502,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;
|
||||
}
|
||||
|
||||
@@ -2606,6 +2742,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>
|
||||
|
||||
@@ -175,7 +184,10 @@
|
||||
|
||||
<div id="info-modal" class="info-modal" aria-hidden="true">
|
||||
<div class="info-card" role="dialog" aria-modal="true" aria-labelledby="info-title">
|
||||
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
||||
<div class="info-actions">
|
||||
<button id="info-copy" class="btn-secondary info-copy" type="button">Copy JSON</button>
|
||||
<button id="info-close" class="info-close" type="button" aria-label="Close">✕</button>
|
||||
</div>
|
||||
<h3 id="info-title">Video Info</h3>
|
||||
<div id="info-list" class="info-list"></div>
|
||||
<div id="info-empty" class="info-empty">No additional info available.</div>
|
||||
@@ -194,6 +206,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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -224,6 +224,10 @@ App.favorites = App.favorites || {};
|
||||
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));
|
||||
@@ -490,7 +494,7 @@ App.favorites = App.favorites || {};
|
||||
`;
|
||||
const thumb = card.querySelector('img');
|
||||
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
||||
App.videos.attachThumbnail(thumb, item.thumb);
|
||||
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item));
|
||||
}
|
||||
card.onclick = () => {
|
||||
if (card.classList.contains('is-loading')) return;
|
||||
|
||||
1045
frontend/js/feed.js
1045
frontend/js/feed.js
File diff suppressed because it is too large
Load Diff
@@ -23,12 +23,25 @@ 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
|
||||
fetchAbort: null // aborts the current attempt's own requests
|
||||
fetchAbort: null, // aborts the current attempt's own requests
|
||||
pending: false, // loading off-screen, not shown yet (see reveal)
|
||||
revealTimer: null,
|
||||
mutedBeforeReveal: null
|
||||
};
|
||||
|
||||
// A session exists: either on screen, or still loading in the background
|
||||
// before it gets there. Everything that used to ask "is the player open?"
|
||||
// means this -- a pending session owns the same history entry, the same
|
||||
// card spinner and the same in-flight requests as a shown one.
|
||||
function isActive() {
|
||||
return !!(cp.container && (cp.pending || cp.container.classList.contains('open')));
|
||||
}
|
||||
App.player.isActive = isActive;
|
||||
|
||||
// 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
|
||||
@@ -59,6 +72,28 @@ App.player = App.player || {};
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
const runCleanups = () => {
|
||||
cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } });
|
||||
@@ -542,7 +577,10 @@ App.player = App.player || {};
|
||||
// ---------------------------------------------------------------------
|
||||
function bindKeyboard(video) {
|
||||
const onKeyDown = (event) => {
|
||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
||||
if (!isActive()) return;
|
||||
// Nothing else is worth doing to a video nobody can see yet, but
|
||||
// changing your mind about it is.
|
||||
if (cp.pending && event.key !== 'Escape') return;
|
||||
switch (event.key) {
|
||||
case ' ':
|
||||
case 'k':
|
||||
@@ -592,7 +630,7 @@ App.player = App.player || {};
|
||||
addCleanup(() => closeBtn.removeEventListener('click', onClick));
|
||||
}
|
||||
const onPopState = () => {
|
||||
if (cp.container && cp.container.classList.contains('open')) {
|
||||
if (isActive()) {
|
||||
cp.historyPushed = false; // the pushed state was just consumed by the browser
|
||||
App.player.close({ fromPopState: true });
|
||||
}
|
||||
@@ -634,7 +672,64 @@ App.player = App.player || {};
|
||||
if (spinner) spinner.classList.toggle('is-visible', show);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Going on screen
|
||||
//
|
||||
// Opening a video used to put an empty black player up immediately and
|
||||
// spin at the viewer until the first frame arrived -- which, between
|
||||
// resolving the formats and the first bytes of a stream, is routinely a
|
||||
// couple of seconds of nothing. So the session now starts off-screen:
|
||||
// the card that was clicked keeps its own spinner, the page stays where
|
||||
// it was, and the player appears only once the video has real data to
|
||||
// show. Failures reveal it too -- the error and its retry live inside the
|
||||
// player -- and so does the timeout below, because a stream that is merely
|
||||
// slow is better watched from inside the player (which can be closed) than
|
||||
// from a card that looks stuck.
|
||||
// ---------------------------------------------------------------------
|
||||
const REVEAL_TIMEOUT_MS = 5000;
|
||||
|
||||
function reveal() {
|
||||
if (!cp.container) return;
|
||||
if (cp.revealTimer) {
|
||||
clearTimeout(cp.revealTimer);
|
||||
cp.revealTimer = null;
|
||||
}
|
||||
cp.pending = false;
|
||||
// The card has handed over, whether this is the first reveal or a
|
||||
// second video opened over the top of the first.
|
||||
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
|
||||
if (cp.container.classList.contains('open')) return;
|
||||
|
||||
cp.container.classList.remove('is-preloading');
|
||||
cp.container.classList.add('open');
|
||||
cp.container.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Muted while it was loading out of sight; it is in sight now.
|
||||
if (cp.mutedBeforeReveal !== null && cp.video) {
|
||||
cp.video.muted = cp.mutedBeforeReveal;
|
||||
cp.mutedBeforeReveal = null;
|
||||
}
|
||||
wakeHud();
|
||||
}
|
||||
|
||||
// The first data is the cue: `loadeddata` means a frame can be drawn, and
|
||||
// `playing` covers the sources that get there without one (audio-only, and
|
||||
// anything whose first frame lands before the listener is attached).
|
||||
function bindReveal(video) {
|
||||
const onData = () => reveal();
|
||||
video.addEventListener('loadeddata', onData);
|
||||
video.addEventListener('playing', onData);
|
||||
addCleanup(() => {
|
||||
video.removeEventListener('loadeddata', onData);
|
||||
video.removeEventListener('playing', onData);
|
||||
});
|
||||
if (video.readyState >= 2) reveal();
|
||||
}
|
||||
|
||||
function showError(message, onRetry, sourceUrl) {
|
||||
// Whatever went wrong, it says so in the player -- which the viewer
|
||||
// can only read if the player is on screen.
|
||||
reveal();
|
||||
showBuffering(false);
|
||||
const errorEl = q('.cp-error');
|
||||
const textEl = q('.cp-error-text');
|
||||
@@ -695,9 +790,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))) || '';
|
||||
|
||||
@@ -830,7 +926,6 @@ App.player = App.player || {};
|
||||
// Whichever candidate got this far is the one on screen -- not
|
||||
// necessarily the one the ranking (or the viewer) asked for.
|
||||
cp.activeUrl = resolved.url || '';
|
||||
clearLoading();
|
||||
hideError();
|
||||
showBuffering(false);
|
||||
if (resumeAt > 0) {
|
||||
@@ -916,16 +1011,17 @@ App.player = App.player || {};
|
||||
// single history.back() could never fully unwind. Also clears the
|
||||
// abandoned session's own loading spinner, since its card would
|
||||
// otherwise never hear about the takeover.
|
||||
const reopening = !!(cp.container && cp.container.classList.contains('open'));
|
||||
const reopening = isActive();
|
||||
if (reopening) {
|
||||
cp.attemptToken++;
|
||||
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();
|
||||
@@ -997,11 +1093,21 @@ App.player = App.player || {};
|
||||
bindKeyboard(cp.video);
|
||||
bindClose();
|
||||
bindBufferingIndicator(cp.video);
|
||||
bindReveal(cp.video);
|
||||
|
||||
cp.container.classList.add('open');
|
||||
cp.container.setAttribute('aria-hidden', 'false');
|
||||
document.body.style.overflow = 'hidden';
|
||||
wakeHud();
|
||||
// Loading out of sight: laid out (so the media element behaves as it
|
||||
// would on screen -- iOS in particular will not load a display:none
|
||||
// video) but transparent and untouchable, so the page underneath is
|
||||
// still the page the viewer is using.
|
||||
if (!cp.container.classList.contains('open')) {
|
||||
cp.pending = true;
|
||||
cp.container.classList.add('is-preloading');
|
||||
// Nothing should be heard from a player that isn't there yet.
|
||||
cp.mutedBeforeReveal = cp.video.muted;
|
||||
cp.video.muted = true;
|
||||
if (cp.revealTimer) clearTimeout(cp.revealTimer);
|
||||
cp.revealTimer = setTimeout(reveal, REVEAL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Already-resolved sources start immediately; unresolved ones start from
|
||||
// the ensureFormats() callback above (the spinner is already up).
|
||||
@@ -1009,7 +1115,7 @@ App.player = App.player || {};
|
||||
};
|
||||
|
||||
App.player.close = function(opts) {
|
||||
if (!cp.container || !cp.container.classList.contains('open')) return;
|
||||
if (!isActive()) return;
|
||||
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
||||
// Closing the player must also stop what it was fetching -- otherwise a
|
||||
// proxied stream keeps being pulled, and the server keeps an upstream
|
||||
@@ -1018,7 +1124,13 @@ App.player = App.player || {};
|
||||
clearIdleTimer();
|
||||
runCleanups();
|
||||
|
||||
cp.container.classList.remove('open', 'cp-hud-idle', 'is-live');
|
||||
if (cp.revealTimer) {
|
||||
clearTimeout(cp.revealTimer);
|
||||
cp.revealTimer = null;
|
||||
}
|
||||
cp.pending = false;
|
||||
cp.mutedBeforeReveal = null;
|
||||
cp.container.classList.remove('open', 'is-preloading', 'cp-hud-idle', 'is-live');
|
||||
cp.container.style.transform = '';
|
||||
cp.container.style.opacity = '';
|
||||
cp.container.setAttribute('aria-hidden', 'true');
|
||||
@@ -1031,10 +1143,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;
|
||||
|
||||
@@ -69,6 +69,62 @@ App.ui = App.ui || {};
|
||||
// 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;
|
||||
// Exactly what the panel is showing, as one object -- what the copy button
|
||||
// hands over. Kept beside the rendering rather than rebuilt on click, so
|
||||
// the two can't disagree about what "this video" means.
|
||||
let infoPayload = null;
|
||||
|
||||
// The clipboard proper needs a secure context, which a home-screen app on
|
||||
// https has and a plain-http LAN address does not -- hence the old
|
||||
// execCommand path behind it, which only works on a selection in the
|
||||
// document.
|
||||
const copyText = async function(text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) { /* fall through to the old way */ }
|
||||
}
|
||||
const scratch = document.createElement('textarea');
|
||||
scratch.value = text;
|
||||
scratch.setAttribute('readonly', '');
|
||||
// Off-screen but focusable: display:none or visibility:hidden would
|
||||
// leave nothing to select, and a visible one would scroll the page.
|
||||
scratch.style.position = 'fixed';
|
||||
scratch.style.top = '-1000px';
|
||||
scratch.style.opacity = '0';
|
||||
document.body.appendChild(scratch);
|
||||
try {
|
||||
scratch.select();
|
||||
return document.execCommand('copy');
|
||||
} catch (err) {
|
||||
return false;
|
||||
} finally {
|
||||
scratch.remove();
|
||||
}
|
||||
};
|
||||
|
||||
let copyResetTimer = null;
|
||||
|
||||
App.ui.copyInfoJson = async function() {
|
||||
const button = document.getElementById('info-copy');
|
||||
if (!infoPayload) return false;
|
||||
const copied = await copyText(JSON.stringify(infoPayload, null, 2));
|
||||
if (!copied) {
|
||||
App.ui.showError('Could not copy to the clipboard.');
|
||||
return false;
|
||||
}
|
||||
if (button) {
|
||||
button.classList.add('is-copied');
|
||||
button.textContent = 'Copied';
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
copyResetTimer = setTimeout(() => {
|
||||
button.classList.remove('is-copied');
|
||||
button.textContent = 'Copy JSON';
|
||||
}, 1600);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const appendInfoHeading = function(list, label) {
|
||||
const heading = document.createElement('div');
|
||||
@@ -131,12 +187,16 @@ App.ui = App.ui || {};
|
||||
|
||||
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
|
||||
|
||||
// `meta` gets its own section below rather than a row of JSON.
|
||||
const own = Object.assign({}, item);
|
||||
delete own.meta;
|
||||
const section = opts.info ? 'extractor' : 'resolved';
|
||||
infoPayload = Object.assign({}, own);
|
||||
if (resolved && typeof resolved === 'object') infoPayload[section] = resolved;
|
||||
|
||||
let rows = 0;
|
||||
if (list) {
|
||||
list.innerHTML = "";
|
||||
// `meta` gets its own section below rather than a row of JSON.
|
||||
const own = Object.assign({}, item);
|
||||
delete own.meta;
|
||||
rows += appendInfoRows(list, own);
|
||||
|
||||
if (resolved && typeof resolved === 'object') {
|
||||
@@ -166,6 +226,13 @@ App.ui = App.ui || {};
|
||||
// nothing (or at a spinner) while it does.
|
||||
App.ui.openInfo = function(video) {
|
||||
infoVideo = video;
|
||||
// A fresh panel, so the button stops saying it copied the last one.
|
||||
const copyBtn = document.getElementById('info-copy');
|
||||
if (copyBtn) {
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer);
|
||||
copyBtn.classList.remove('is-copied');
|
||||
copyBtn.textContent = 'Copy JSON';
|
||||
}
|
||||
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
|
||||
App.ui.showInfo(video, { pending: canResolve });
|
||||
if (!canResolve) return;
|
||||
@@ -179,6 +246,7 @@ App.ui = App.ui || {};
|
||||
const modal = document.getElementById('info-modal');
|
||||
if (!modal) return;
|
||||
infoVideo = null;
|
||||
infoPayload = null;
|
||||
modal.classList.remove('open');
|
||||
modal.setAttribute('aria-hidden', 'true');
|
||||
};
|
||||
@@ -229,12 +297,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');
|
||||
@@ -242,7 +654,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) => {
|
||||
@@ -281,80 +693,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);
|
||||
|
||||
@@ -827,5 +1166,12 @@ App.ui = App.ui || {};
|
||||
App.ui.closeInfo();
|
||||
});
|
||||
}
|
||||
|
||||
const infoCopy = document.getElementById('info-copy');
|
||||
if (infoCopy) {
|
||||
infoCopy.addEventListener('click', () => {
|
||||
App.ui.copyInfoJson();
|
||||
});
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -54,9 +54,12 @@ App.version = App.version || {};
|
||||
// a reload because it is restored from localStorage on boot.
|
||||
function isSafeToReload() {
|
||||
if (App.state && App.state.feedOpen) return false;
|
||||
const player = document.getElementById('custom-player');
|
||||
if (player && player.classList.contains('open')) {
|
||||
const video = player.querySelector('.cp-video');
|
||||
// A player still loading out of sight counts as in use: reloading
|
||||
// would throw away the video the viewer just asked for.
|
||||
if (App.player && typeof App.player.isActive === 'function' && App.player.isActive()) {
|
||||
const player = document.getElementById('custom-player');
|
||||
const video = player && player.querySelector('.cp-video');
|
||||
if (!player.classList.contains('open')) return false;
|
||||
if (video && !video.paused && !video.ended) return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
206
tests/smoke_channels.py
Normal file
206
tests/smoke_channels.py
Normal file
@@ -0,0 +1,206 @@
|
||||
#!/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",
|
||||
# Lean flags, matching smoke_grid: 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.
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
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())
|
||||
151
tests/smoke_info.py
Normal file
151
tests/smoke_info.py
Normal file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Info panel smoke tests: what the panel shows, and what Copy JSON hands over.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_info.py
|
||||
|
||||
The listing is served by this script so the expected JSON is known exactly --
|
||||
including the awkward parts of a real item (nested http_headers, a tag array,
|
||||
a zero, an empty string).
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
ITEM = {
|
||||
"id": "test:copy-me",
|
||||
"title": "Summer Col and Damion Dayski",
|
||||
"url": "https://example.test/proxy/test/post/copy-me.html",
|
||||
"channel": "test",
|
||||
"duration": 0,
|
||||
"isLive": False,
|
||||
"views": 0,
|
||||
"uploader": "",
|
||||
"thumb": "https://cdn.example.test/thumb.png",
|
||||
"tags": ["sex", "bigtits", "bigass", "hardcore"],
|
||||
"preview": "https://example.test/proxy/test/post/copy-me.html",
|
||||
"http_headers": {"Referer": "https://example.test/"},
|
||||
}
|
||||
|
||||
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([]));
|
||||
}"""
|
||||
|
||||
|
||||
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 main():
|
||||
c = Checks()
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
# Lean flags, matching smoke_grid: 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.
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
context = browser.new_context(viewport={"width": 1400, "height": 1000})
|
||||
# The real clipboard, so the button's own path is what runs.
|
||||
context.grant_permissions(["clipboard-read", "clipboard-write"])
|
||||
page = context.new_page()
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
page.route("**/api/videos", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json",
|
||||
body=json.dumps({"items": [ITEM], "pageInfo": {"hasNextPage": False}})))
|
||||
page.route("**/api/resolve*", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json",
|
||||
body=json.dumps({"formats": [], "http_headers": {}, "isLive": False, "url": None})))
|
||||
|
||||
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)
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
print("\nthe panel opens with a copy button")
|
||||
page.evaluate("""() => {
|
||||
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
|
||||
App.ui.openInfo(v);
|
||||
}""")
|
||||
page.wait_for_timeout(500)
|
||||
c.ok("the panel is open",
|
||||
page.evaluate("() => document.getElementById('info-modal').classList.contains('open')"))
|
||||
c.ok("the button is visible", page.is_visible("#info-copy"))
|
||||
c.ok("and reads Copy JSON",
|
||||
page.inner_text("#info-copy").strip() == "Copy JSON",
|
||||
page.inner_text("#info-copy"))
|
||||
|
||||
print("\nclicking it puts the item on the clipboard")
|
||||
page.click("#info-copy")
|
||||
page.wait_for_timeout(600)
|
||||
clipboard = page.evaluate("() => navigator.clipboard.readText()")
|
||||
try:
|
||||
copied = json.loads(clipboard)
|
||||
except ValueError:
|
||||
copied = None
|
||||
c.ok("what landed there is JSON", copied is not None, clipboard[:120])
|
||||
if copied:
|
||||
missing = [k for k, v in ITEM.items() if k not in copied]
|
||||
c.ok("with every field the item has", not missing, str(missing))
|
||||
c.ok("values intact, nested ones included",
|
||||
copied.get("http_headers") == ITEM["http_headers"]
|
||||
and copied.get("tags") == ITEM["tags"], str(copied.get("http_headers")))
|
||||
c.ok("a zero is a zero, not a dash",
|
||||
copied.get("duration") == 0 and copied.get("views") == 0, str(copied.get("duration")))
|
||||
c.ok("and an empty string stays empty",
|
||||
copied.get("uploader") == "", repr(copied.get("uploader")))
|
||||
c.ok("it is pretty-printed, not one line", "\n" in clipboard)
|
||||
c.ok("the button says so", page.inner_text("#info-copy").strip() == "Copied",
|
||||
page.inner_text("#info-copy"))
|
||||
|
||||
print("\nand it goes back to normal")
|
||||
page.wait_for_timeout(2000)
|
||||
c.ok("the label resets", page.inner_text("#info-copy").strip() == "Copy JSON",
|
||||
page.inner_text("#info-copy"))
|
||||
|
||||
print("\nthe resolved payload rides along once it lands")
|
||||
page.evaluate("""() => {
|
||||
const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
|
||||
App.ui.showInfo(v, { info: { extractorField: 'yes', formats: [] } });
|
||||
}""")
|
||||
page.wait_for_timeout(300)
|
||||
page.click("#info-copy")
|
||||
page.wait_for_timeout(600)
|
||||
second = json.loads(page.evaluate("() => navigator.clipboard.readText()"))
|
||||
c.ok("the extractor's fields are in there too",
|
||||
(second.get("extractor") or {}).get("extractorField") == "yes",
|
||||
str(list(second.keys())))
|
||||
c.ok("and the item's own are still there", second.get("id") == ITEM["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())
|
||||
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())
|
||||
179
tests/smoke_ios_split.py
Normal file
179
tests/smoke_ios_split.py
Normal file
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Split reels on iOS: every panel keeps playing, one panel keeps the sound.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_ios_split.py
|
||||
|
||||
There is no iPhone here, and the bug was never about what the video element
|
||||
does once asked -- it is about what iOS does to the *other* video when one
|
||||
starts with sound. So the rule is installed into Chromium and the real feed is
|
||||
driven through it: unmuting a panel used to unmute every panel, and the system
|
||||
would then stop all but one of them.
|
||||
"""
|
||||
import sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = "http://127.0.0.1:5000/"
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
IPHONE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1")
|
||||
|
||||
# iOS's rule, near enough: one audible video at a time. Whatever starts (or
|
||||
# unmutes) with sound takes the audio session, and whatever had it is paused.
|
||||
AS_IOS = """(() => {
|
||||
window.__systemPaused = [];
|
||||
const claimAudio = (winner) => {
|
||||
document.querySelectorAll('video').forEach((other) => {
|
||||
if (other === winner || other.paused || other.muted) return;
|
||||
window.__systemPaused.push(other.currentSrc || 'video');
|
||||
HTMLMediaElement.prototype.pause.call(other);
|
||||
});
|
||||
};
|
||||
const play = HTMLMediaElement.prototype.play;
|
||||
HTMLMediaElement.prototype.play = function() {
|
||||
if (!this.muted) claimAudio(this);
|
||||
return play.apply(this, arguments);
|
||||
};
|
||||
const muted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
|
||||
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
|
||||
configurable: true,
|
||||
get() { return muted.get.call(this); },
|
||||
set(value) {
|
||||
muted.set.call(this, value);
|
||||
if (!value && !this.paused) claimAudio(this);
|
||||
},
|
||||
});
|
||||
})();"""
|
||||
|
||||
PANES = """() => {
|
||||
const slide = document.querySelector('.feed-slide.is-active');
|
||||
if (!slide) return null;
|
||||
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
|
||||
return panes.map((pane) => {
|
||||
const video = pane.querySelector('.feed-video');
|
||||
const btn = pane.querySelector('.feed-pane-mute');
|
||||
return {
|
||||
muted: video ? video.muted : null,
|
||||
flag: pane._muted === undefined ? null : !!pane._muted,
|
||||
paused: video ? video.paused : null,
|
||||
loaded: pane.classList.contains('is-loaded'),
|
||||
button: btn ? btn.textContent : '',
|
||||
};
|
||||
});
|
||||
}"""
|
||||
|
||||
|
||||
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 open_reels(page):
|
||||
page.goto(BASE, wait_until="domcontentloaded")
|
||||
page.evaluate("""([server, channel]) => {
|
||||
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
|
||||
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
|
||||
localStorage.removeItem('session');
|
||||
}""", [SERVER, CHANNEL])
|
||||
page.goto(BASE, wait_until="load")
|
||||
page.wait_for_selector(".video-card", timeout=90000)
|
||||
page.wait_for_timeout(1500)
|
||||
page.evaluate("() => App.feed.open()")
|
||||
page.wait_for_selector(".feed-slide", timeout=20000)
|
||||
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",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
context = browser.new_context(
|
||||
viewport={"width": 430, "height": 930},
|
||||
user_agent=IPHONE_UA, is_mobile=True, has_touch=True,
|
||||
device_scale_factor=3)
|
||||
context.add_init_script(AS_IOS)
|
||||
page = context.new_page()
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
open_reels(page)
|
||||
|
||||
print("\nthe rule is really in force")
|
||||
c.ok("the page says it is an iPhone",
|
||||
"iPhone" in page.evaluate("() => navigator.userAgent"))
|
||||
|
||||
print("\nsplit in two, with sound on")
|
||||
page.click(".feed-slide.is-active .feed-pane .feed-pane-split-right")
|
||||
page.wait_for_timeout(5000)
|
||||
page.evaluate("() => App.feed.toggleMute()") # the feed-wide unmute
|
||||
page.wait_for_timeout(3000)
|
||||
panes = page.evaluate(PANES)
|
||||
c.ok("there are two panels", panes and len(panes) == 2, str(panes))
|
||||
audible = [p for p in panes if p["muted"] is False]
|
||||
c.ok("exactly one of them has sound", len(audible) == 1, str(panes))
|
||||
c.ok("and the other says so on its own button",
|
||||
all(p["button"] == "🔇" for p in panes if p["muted"]), str(panes))
|
||||
c.ok("the flags agree with the elements",
|
||||
all(p["flag"] == p["muted"] for p in panes), str(panes))
|
||||
c.ok("both panels are playing",
|
||||
all(p["paused"] is False for p in panes if p["loaded"]), str(panes))
|
||||
|
||||
print("\nmoving the sound to the other panel")
|
||||
page.click(".feed-slide.is-active .feed-pane:last-of-type .feed-pane-mute")
|
||||
page.wait_for_timeout(2500)
|
||||
moved = page.evaluate(PANES)
|
||||
c.ok("the panel asked for has it", moved[-1]["muted"] is False, str(moved))
|
||||
c.ok("the first one gave it up", moved[0]["muted"] is True, str(moved))
|
||||
c.ok("and nothing stopped playing",
|
||||
all(p["paused"] is False for p in moved if p["loaded"]), str(moved))
|
||||
|
||||
print("\nswiping on")
|
||||
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
|
||||
" s.scrollTop += s.clientHeight; }")
|
||||
page.wait_for_timeout(5000)
|
||||
stepped = page.evaluate(PANES)
|
||||
c.ok("the next step plays in both panels",
|
||||
stepped and all(p["paused"] is False for p in stepped if p["loaded"]),
|
||||
str(stepped))
|
||||
c.ok("still only one audible", len([p for p in stepped if p["muted"] is False]) <= 1,
|
||||
str(stepped))
|
||||
|
||||
# Without the fix, both panels would be unmuted -- so prove the rule
|
||||
# installed above actually bites, by breaking it on purpose.
|
||||
print("\nthe simulated rule is what the fix is for")
|
||||
page.evaluate("""() => {
|
||||
document.querySelectorAll('.feed-slide.is-active .feed-video').forEach((v) => {
|
||||
v.muted = false;
|
||||
const p = v.play();
|
||||
if (p && p.catch) p.catch(() => {});
|
||||
});
|
||||
}""")
|
||||
page.wait_for_timeout(1500)
|
||||
forced = page.evaluate(PANES)
|
||||
c.ok("unmuting every panel does stop one of them",
|
||||
any(p["paused"] for p in forced if p["loaded"]), str(forced))
|
||||
|
||||
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())
|
||||
276
tests/smoke_open.py
Normal file
276
tests/smoke_open.py
Normal file
@@ -0,0 +1,276 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opening a video: load first, show the player once there's something to show.
|
||||
|
||||
Run against a locally running backend:
|
||||
|
||||
backend/main.py &
|
||||
.venv/bin/python tests/smoke_open.py
|
||||
|
||||
Both the listing and the media are served by this script, so "the stream is
|
||||
slow" and "the stream is broken" are conditions the test can actually create
|
||||
rather than wait for.
|
||||
"""
|
||||
import base64
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import socketserver
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
|
||||
# One second of black, 64x64, h264 -- a real file, because the point of the
|
||||
# test is that the browser decodes a frame from it.
|
||||
MP4_B64 = (
|
||||
"AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAOxbW9vdgAAAGxtdmhkAAAAAAAAAAAA"
|
||||
"AAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAA"
|
||||
"AABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAtx0cmFrAAAAXHRraGQAAAADAAAA"
|
||||
"AAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAA"
|
||||
"AAAAAAAAAABAAAAAAEAAAABAAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAIAAABAAAA"
|
||||
"AAJUbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAAAoAAAAKABVxAAAAAAALWhkbHIAAAAAAAAAAHZp"
|
||||
"ZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAAB/21pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAA"
|
||||
"ACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAb9zdGJsAAAAv3N0c2QAAAAAAAAA"
|
||||
"AQAAAK9hdmMxAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAQABIAAAASAAAAAAAAAABFUxhdmM2"
|
||||
"MS4xOS4xMDEgbGlieDI2NAAAAAAAAAAAAAAAGP//AAAANWF2Y0MBZAAK/+EAGGdkAAqs2UQmwEQA"
|
||||
"AAMABAAAAwBQPEiWWAEABmjr48siwP34+AAAAAAQcGFzcAAAAAEAAAABAAAAFGJ0cnQAAAAAAAAa"
|
||||
"uAAAAAAAAAAYc3R0cwAAAAAAAAABAAAACgAABAAAAAAUc3RzcwAAAAAAAAABAAAAAQAAAGBjdHRz"
|
||||
"AAAAAAAAAAoAAAABAAAIAAAAAAEAABQAAAAAAQAACAAAAAABAAAAAAAAAAEAAAQAAAAAAQAAFAAA"
|
||||
"AAABAAAIAAAAAAEAAAAAAAAAAQAABAAAAAABAAAIAAAAABxzdHNjAAAAAAAAAAEAAAABAAAACgAA"
|
||||
"AAEAAAA8c3RzegAAAAAAAAAAAAAACgAAAtcAAAAOAAAADAAAAAwAAAAMAAAAFAAAAA4AAAAMAAAA"
|
||||
"DAAAABQAAAAUc3RjbwAAAAAAAAABAAAD4QAAAGF1ZHRhAAAAWW1ldGEAAAAAAAAAIWhkbHIAAAAA"
|
||||
"AAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAALGlsc3QAAAAkqXRvbwAAABxkYXRhAAAAAQAAAABMYXZm"
|
||||
"NjEuNy4xMDAAAAAIZnJlZQAAA19tZGF0AAACrgYF//+q3EXpvebZSLeWLNgg2SPu73gyNjQgLSBj"
|
||||
"b3JlIDE2NCByMzEwOCAzMWUxOWY5IC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0"
|
||||
"IDIwMDMtMjAyMyAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6"
|
||||
"IGNhYmFjPTEgcmVmPTMgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MzoweDExMyBtZT1oZXggc3Vi"
|
||||
"bWU9NyBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0xIG1lX3JhbmdlPTE2IGNocm9t"
|
||||
"YV9tZT0xIHRyZWxsaXM9MSA4eDhkY3Q9MSBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lw"
|
||||
"PTEgY2hyb21hX3FwX29mZnNldD0tMiB0aHJlYWRzPTIgbG9va2FoZWFkX3RocmVhZHM9MSBzbGlj"
|
||||
"ZWRfdGhyZWFkcz0wIG5yPTAgZGVjaW1hdGU9MSBpbnRlcmxhY2VkPTAgYmx1cmF5X2NvbXBhdD0w"
|
||||
"IGNvbnN0cmFpbmVkX2ludHJhPTAgYmZyYW1lcz0zIGJfcHlyYW1pZD0yIGJfYWRhcHQ9MSBiX2Jp"
|
||||
"YXM9MCBkaXJlY3Q9MSB3ZWlnaHRiPTEgb3Blbl9nb3A9MCB3ZWlnaHRwPTIga2V5aW50PTI1MCBr"
|
||||
"ZXlpbnRfbWluPTEwIHNjZW5lY3V0PTQwIGludHJhX3JlZnJlc2g9MCByY19sb29rYWhlYWQ9NDAg"
|
||||
"cmM9Y3JmIG1idHJlZT0xIGNyZj0yMy4wIHFjb21wPTAuNjAgcXBtaW49MCBxcG1heD02OSBxcHN0"
|
||||
"ZXA9NCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAIWWIhAAR//73iB8yy2+catdyEeesVP1G"
|
||||
"Ixltc+dmuhineQAAAApBmiRsQQ/+qlfeAAAACEGeQniHfwW9AAAACAGeYXRDfwd8AAAACAGeY2pD"
|
||||
"fwd9AAAAEEGaaEmoQWiZTAh3//6pnTUAAAAKQZ6GRREsO/8FvQAAAAgBnqV0Q38HfQAAAAgBnqdq"
|
||||
"Q38HfAAAABBBmqlJqEFsmUwIb//+p4+I"
|
||||
)
|
||||
|
||||
|
||||
def make_server(delay_holder):
|
||||
"""Serves the fixture video, optionally after a delay, plus a dead URL."""
|
||||
|
||||
class Handler(http.server.BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/slow") or self.path.startswith("/fast"):
|
||||
time.sleep(delay_holder["slow"] if self.path.startswith("/slow") else 0)
|
||||
body = base64.b64decode(MP4_B64)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Accept-Ranges", "none")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
def do_HEAD(self):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
|
||||
httpd = socketserver.ThreadingTCPServer(("127.0.0.1", 0), Handler)
|
||||
httpd.daemon_threads = True
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
return httpd
|
||||
|
||||
|
||||
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 = """() => {
|
||||
const player = document.getElementById('custom-player');
|
||||
const video = player.querySelector('.cp-video');
|
||||
const card = document.querySelector('#video-grid .video-card');
|
||||
return {
|
||||
shown: player.classList.contains('open'),
|
||||
preloading: player.classList.contains('is-preloading'),
|
||||
active: !!(App.player.isActive && App.player.isActive()),
|
||||
cardBusy: !!(card && card.classList.contains('is-loading')),
|
||||
src: video ? (video.currentSrc || video.getAttribute('src') || '') : '',
|
||||
readyState: video ? video.readyState : -1,
|
||||
muted: video ? video.muted : null,
|
||||
bodyOverflow: document.body.style.overflow,
|
||||
error: !!(player.querySelector('.cp-error') && !player.querySelector('.cp-error').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 item(video_url, ident="test:0"):
|
||||
"""One listing item whose formats are already resolved, so opening it goes
|
||||
straight to playback rather than through a resolve first."""
|
||||
return {
|
||||
"id": ident,
|
||||
"title": "A video",
|
||||
"url": "https://example.test/watch",
|
||||
"channel": "test",
|
||||
"duration": 1,
|
||||
"thumb": "",
|
||||
"tags": [],
|
||||
"meta": {
|
||||
"url": video_url,
|
||||
"http_headers": {},
|
||||
"isLive": False,
|
||||
"formats": [{"url": video_url, "ext": "mp4", "height": 240, "protocol": "https",
|
||||
"vcodec": "avc1", "acodec": "none"}],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def open_first(page):
|
||||
"""Click the first card. Forced, because the hover preview parks a <video>
|
||||
over the thumbnail -- which is inside the card and so opens it just the
|
||||
same, but Playwright won't click through it on its own."""
|
||||
page.click("#video-grid .video-card", force=True)
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
delay = {"slow": 0}
|
||||
httpd = make_server(delay)
|
||||
media = f"http://127.0.0.1:{httpd.server_address[1]}"
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
page = browser.new_page(viewport={"width": 1400, "height": 1000})
|
||||
crashes = []
|
||||
page.on("pageerror", lambda e: crashes.append(str(e)))
|
||||
|
||||
items = {"body": [item(f"{media}/fast.mp4")]}
|
||||
page.route("**/api/videos", lambda route: route.fulfill(
|
||||
status=200, content_type="application/json",
|
||||
body=json.dumps({"items": items["body"], "pageInfo": {"hasNextPage": False}})))
|
||||
|
||||
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)
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
print("\na video that loads quickly")
|
||||
open_first(page)
|
||||
page.wait_for_timeout(2500)
|
||||
after = page.evaluate(STATE)
|
||||
c.ok("the player is on screen", after["shown"], str(after))
|
||||
c.ok("it is no longer preloading", not after["preloading"], str(after))
|
||||
c.ok("with data, not an empty frame", after["readyState"] >= 2, str(after["readyState"]))
|
||||
c.ok("the card has stopped spinning", not after["cardBusy"], str(after))
|
||||
c.ok("sound is back on", after["muted"] is False, str(after["muted"]))
|
||||
c.ok("and the page behind it is locked", after["bodyOverflow"] == "hidden",
|
||||
after["bodyOverflow"])
|
||||
page.evaluate("() => App.player.close()")
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
print("\na video that takes its time")
|
||||
delay["slow"] = 3.0
|
||||
items["body"] = [item(f"{media}/slow.mp4", "test:slow")]
|
||||
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
|
||||
page.wait_for_selector(".video-card", timeout=60000)
|
||||
page.wait_for_timeout(600)
|
||||
open_first(page)
|
||||
page.wait_for_timeout(900)
|
||||
during = page.evaluate(STATE)
|
||||
c.ok("the player is not up yet", not during["shown"], str(during))
|
||||
c.ok("but the session is live", during["active"] and during["preloading"], str(during))
|
||||
c.ok("the card says it is working on it", during["cardBusy"], str(during))
|
||||
c.ok("nothing can be heard from it", during["muted"] is True, str(during["muted"]))
|
||||
c.ok("and the page is still the page", during["bodyOverflow"] != "hidden",
|
||||
during["bodyOverflow"])
|
||||
# The grid is still usable underneath: the invisible player must not be
|
||||
# eating clicks.
|
||||
c.ok("the grid underneath is still reachable",
|
||||
page.evaluate("""() => {
|
||||
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2);
|
||||
return !!(el && !el.closest('#custom-player'));
|
||||
}"""))
|
||||
page.wait_for_timeout(4000)
|
||||
later = page.evaluate(STATE)
|
||||
c.ok("once the data lands, the player appears", later["shown"], str(later))
|
||||
c.ok("and the card is released", not later["cardBusy"], str(later))
|
||||
page.evaluate("() => App.player.close()")
|
||||
page.wait_for_timeout(600)
|
||||
|
||||
print("\nchanging your mind while it loads")
|
||||
delay["slow"] = 8.0
|
||||
open_first(page)
|
||||
page.wait_for_timeout(700)
|
||||
c.ok("Escape cancels a pending open",
|
||||
page.evaluate("""() => {
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
return true;
|
||||
}"""))
|
||||
page.wait_for_timeout(600)
|
||||
cancelled = page.evaluate(STATE)
|
||||
c.ok("nothing is left running", not cancelled["active"], str(cancelled))
|
||||
c.ok("the player never appeared", not cancelled["shown"], str(cancelled))
|
||||
c.ok("and the card is free again", not cancelled["cardBusy"], str(cancelled))
|
||||
|
||||
print("\na video that will not load at all")
|
||||
items["body"] = [item(f"{media}/nope.mp4", "test:dead")]
|
||||
page.evaluate("() => { App.videos.resetGrid(); App.videos.loadVideos(); }")
|
||||
page.wait_for_selector(".video-card", timeout=60000)
|
||||
page.wait_for_timeout(600)
|
||||
open_first(page)
|
||||
page.wait_for_timeout(6000)
|
||||
failed = page.evaluate(STATE)
|
||||
c.ok("the player is shown so the failure can be read", failed["shown"], str(failed))
|
||||
c.ok("with its error up", failed["error"], str(failed))
|
||||
c.ok("and the card no longer spins", not failed["cardBusy"], str(failed))
|
||||
page.evaluate("() => App.player.close()")
|
||||
|
||||
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
|
||||
browser.close()
|
||||
|
||||
httpd.shutdown()
|
||||
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())
|
||||
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())
|
||||
333
tests/smoke_thumbnails.py
Normal file
333
tests/smoke_thumbnails.py
Normal file
@@ -0,0 +1,333 @@
|
||||
#!/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.
|
||||
|
||||
* a thumbnail that is simply gone. sxyprn's CDN paths are signed with an
|
||||
expiry and the listing hands out URLs that have passed theirs, so the
|
||||
newest posts arrive pointing at a 404. The item's own page still shows a
|
||||
working picture, and /api/poster reads it off.
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
|
||||
SERVER = "https://hottubapp.io"
|
||||
CHANNEL = "xvideos"
|
||||
CDN = "https://cdn.example-thumbs.test"
|
||||
# Where a "provider page" lives, and the picture it says it has -- the shape
|
||||
# /api/poster reads. The proxy path mirrors the Hot Tub server's own, so the
|
||||
# client has to rebuild the site address from the item's Referer to find it.
|
||||
SITE = "https://site.example-thumbs.test"
|
||||
PROXY_PAGE = "https://proxy.example-thumbs.test/proxy/test/post/gone.html"
|
||||
|
||||
# 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,
|
||||
};
|
||||
})"""
|
||||
|
||||
|
||||
PAGE_HTML = """<!doctype html><html><head>
|
||||
<meta property='og:title' content='A video'/>
|
||||
<meta property='og:image' content='//pictures.example.test/fresh/poster.webp'/>
|
||||
<meta itemprop="thumbnailUrl" content="//pictures.example.test/other.webp" />
|
||||
</head><body><video poster='//pictures.example.test/player.webp'></video></body></html>"""
|
||||
|
||||
|
||||
class PageServer(threading.Thread):
|
||||
"""A page for /api/poster to read, and one that redirects to a video --
|
||||
which is what the Hot Tub proxy does, and what must not be downloaded."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True)
|
||||
outer = self
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/post"):
|
||||
body = PAGE_HTML.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
if self.path.startswith("/video"):
|
||||
body = b"\0" * (4 * 1024 * 1024)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "video/mp4")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
self.httpd = HTTPServer(("127.0.0.1", 0), Handler)
|
||||
outer.port = self.httpd.server_port
|
||||
|
||||
def run(self):
|
||||
self.httpd.serve_forever()
|
||||
|
||||
def stop(self):
|
||||
self.httpd.shutdown()
|
||||
|
||||
|
||||
GONE_STATE = """() => {
|
||||
const card = document.querySelector('#video-grid .video-card[data-video-id="test:9"]');
|
||||
const img = card && card.querySelector('img');
|
||||
return { src: img ? img.getAttribute('src') || '' : '', loaded: !!(img && img.naturalWidth > 0) };
|
||||
}"""
|
||||
|
||||
|
||||
def gone_state(page):
|
||||
return page.evaluate(GONE_STATE)
|
||||
|
||||
|
||||
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"
|
||||
# The expired-signature case: a dead thumbnail, and an item that says
|
||||
# enough about where it came from for the page to be found.
|
||||
items[9]["thumb"] = f"{CDN}/expired/gone.jpg"
|
||||
items[9]["url"] = PROXY_PAGE
|
||||
items[9]["channel"] = "test"
|
||||
items[9]["http_headers"] = {"Referer": f"{SITE}/"}
|
||||
return {"items": items, "pageInfo": {"hasNextPage": False}}
|
||||
|
||||
|
||||
def poster_of(page_url):
|
||||
"""What /api/poster answers for a page, straight from the backend.
|
||||
|
||||
Returns None if the backend doesn't have the endpoint -- which on a machine
|
||||
that has been running since before it existed means "restart it", not
|
||||
"broken"."""
|
||||
url = BASE.rstrip("/") + "/api/poster?url=" + urllib.parse.quote(page_url, safe="")
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=30) as response:
|
||||
return json.load(response)
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
c = Checks()
|
||||
image_proxy_calls = []
|
||||
poster_asks = []
|
||||
flaky_hits = {"direct": 0, "proxy": 0}
|
||||
|
||||
pages = PageServer()
|
||||
pages.start()
|
||||
local = f"http://127.0.0.1:{pages.port}"
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(args=[
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
# Lean flags, matching smoke_grid: 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.
|
||||
"--renderer-process-limit=1",
|
||||
"--js-flags=--max-old-space-size=512",
|
||||
])
|
||||
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 "%2Fexpired%2F" in route.request.url or "/expired/" in route.request.url:
|
||||
route.fulfill(status=404, content_type="text/html", body="gone")
|
||||
return
|
||||
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_poster(route):
|
||||
# The endpoint itself is exercised against a real page below; here
|
||||
# only the client's half is under test, so the answer is canned.
|
||||
asked = urllib.parse.parse_qs(
|
||||
urllib.parse.urlparse(route.request.url).query).get("url", [""])[0]
|
||||
poster_asks.append(asked)
|
||||
route.fulfill(status=200, content_type="application/json",
|
||||
body=json.dumps({"thumb": f"{CDN}/repaired.png"}))
|
||||
|
||||
def serve_cdn(route):
|
||||
if "/expired/" in route.request.url:
|
||||
route.fulfill(status=404, content_type="text/html", body="gone")
|
||||
return
|
||||
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/poster*", serve_poster)
|
||||
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")
|
||||
# test:9's thumbnail is deliberately dead; it has a section of its own.
|
||||
others = [card for card in cards if card["id"] not in ("test:3", "test:9")]
|
||||
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']}")
|
||||
|
||||
print("\na thumbnail that is simply gone")
|
||||
gone = by_id.get("test:9")
|
||||
c.ok("the card is mounted", gone is not None)
|
||||
c.ok("its page was asked what picture it shows", poster_asks,
|
||||
str(poster_asks))
|
||||
# The item points at a proxy path; the page lives on the site named by
|
||||
# the Referer the item carries.
|
||||
c.ok("and the page asked for was the item's own, on its own site",
|
||||
poster_asks and poster_asks[0] == f"{SITE}/post/gone.html",
|
||||
str(poster_asks[:2]))
|
||||
c.ok("the card ends up showing the replacement",
|
||||
gone and gone_state(page)["loaded"], str(gone_state(page)))
|
||||
c.ok("one ask is enough for that page", len(poster_asks) == 1, str(poster_asks))
|
||||
|
||||
print("\nwhat /api/poster reads off a page")
|
||||
answer = poster_of(f"{local}/post.html")
|
||||
if answer is None:
|
||||
c.ok("the backend has /api/poster (restart it if this fails)", False)
|
||||
answer = {}
|
||||
else:
|
||||
c.ok("the backend has /api/poster", True)
|
||||
c.ok("the page's own og:image, made absolute",
|
||||
answer.get("thumb") == f"http://pictures.example.test/fresh/poster.webp",
|
||||
str(answer))
|
||||
video = poster_of(f"{local}/video.mp4")
|
||||
c.ok("a URL that turns out to be a video yields nothing",
|
||||
video.get("thumb") is None, str(video))
|
||||
missing = poster_of(f"{local}/nope.html")
|
||||
c.ok("and so does a page that isn't there", missing.get("thumb") is None, str(missing))
|
||||
|
||||
browser.close()
|
||||
pages.stop()
|
||||
|
||||
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