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:
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