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

View File

@@ -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; }

View File

@@ -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');

View File

@@ -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;
// 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');
if (player && player.classList.contains('open')) {
const video = player.querySelector('.cp-video');
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
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())