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:
Simon
2026-09-21 16:39:03 +00:00
parent 4b03789ef2
commit b4bc90372d
4 changed files with 396 additions and 14 deletions

276
tests/smoke_open.py Normal file
View 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())