Load a video before showing the player, not after
Opening a video put an empty black player up at once and spun 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 to look at. The session now starts off-screen: everything is built and loading, but the card that was clicked keeps its own spinner, the page stays where it was, and the player appears when the video has something to show. Failures reveal it too, since the error and its retry live inside the player, and so does a five-second timeout -- a stream that is merely slow is better watched from inside a player that can be closed than from a card that looks stuck. The video is muted while it loads out of sight and restored on the way in, because nothing should be heard from a player that isn't there. It is hidden with opacity rather than display:none: iOS won't load a display:none video, and the HUD's children opt back into pointer events, so they are told not to as well -- an invisible close button must not eat a card's click. isActive() replaces the checks that asked "is the player open?" and meant "is there a session": a pending one owns the same history entry, the same card spinner and the same requests, so Escape cancels it, the back gesture cancels it, another video takes over from it, and an update can't reload the page out from under it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -1528,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; }
|
||||
|
||||
@@ -27,9 +27,21 @@ App.player = App.player || {};
|
||||
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
|
||||
@@ -565,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':
|
||||
@@ -615,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 });
|
||||
}
|
||||
@@ -657,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');
|
||||
@@ -854,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) {
|
||||
@@ -940,7 +1011,7 @@ 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();
|
||||
@@ -1022,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).
|
||||
@@ -1034,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
|
||||
@@ -1043,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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user