diff --git a/frontend/css/style.css b/frontend/css/style.css index d1f1d57..f3665de 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -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; } diff --git a/frontend/js/player.js b/frontend/js/player.js index 6aa0ba0..6742843 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -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'); diff --git a/frontend/js/version.js b/frontend/js/version.js index d2fda1a..7122f16 100644 --- a/frontend/js/version.js +++ b/frontend/js/version.js @@ -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; diff --git a/tests/smoke_open.py b/tests/smoke_open.py new file mode 100644 index 0000000..3de67fb --- /dev/null +++ b/tests/smoke_open.py @@ -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