Compare commits

...

2 Commits

Author SHA1 Message Date
Simon
b8a51d9ee2 Keep every panel playing on iOS, and give one of them the sound
iOS gives a page one audible video at a time. Start a second one with
sound and the system pauses the first, so on an iPhone a split of two
showed one panel playing and one stopped -- the feed-wide unmute set every
panel audible, and the panels then took the audio session from each other
in turn.

Muted video carries no such limit, so where the rule applies exactly one
panel keeps its sound and the rest keep their picture: four pictures moving
is what the split is for, and four audio tracks at once was never the
point. The sound follows whichever panel is asked for it, and a panel the
system stopped on the way is started again.

Nothing in the platform announces the rule, and by the time the symptom
shows -- a video we started, stopped by something that isn't us -- a panel
has already died, so it is read off the device rather than discovered.
Everywhere else nothing changes: panels keep their own sound.

No iPhone here, so the rule is installed into Chromium and the real feed is
driven through it. The last check breaks it on purpose -- unmuting every
panel by hand does stop one -- so the simulation can't quietly become a
no-op. Against the code before this commit the test fails seven checks,
showing exactly what was reported: both panels unmuted, the first paused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 16:39:17 +00:00
Simon
b4bc90372d 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
2026-09-21 16:39:03 +00:00
6 changed files with 640 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

@@ -135,6 +135,59 @@ App.feed = App.feed || {};
if (scroller) scroller.scrollTo({ top: next * slideHeight(), behavior: 'smooth' });
};
// ------------------------------------------------------------------
// Sound in a split
//
// iOS gives a page one audible video at a time. Start a second one with
// sound and the system pauses the first -- which, in a split of two or
// four panels, reads as "only one panel is playing". Muted video carries
// no such limit, so where the rule applies exactly one panel keeps its
// sound and the rest are silenced: four pictures moving is what the split
// is for, and four audio tracks at once was never the point.
//
// Nothing in the platform announces this, and by the time the symptom
// shows -- a video we started, stopped by something that isn't us -- the
// reader has already watched a panel die. So it is read off the device
// rather than discovered.
const audioIsExclusive = (function() {
const ua = navigator.userAgent || '';
if (/iPhone|iPad|iPod/.test(ua)) return true;
// iPadOS 13+ claims to be a Mac; the touch points give it away.
return /Macintosh/.test(ua) && (navigator.maxTouchPoints || 0) > 1;
})();
// Leaves at most one audible panel in the step being watched. `prefer` is
// the panel whose sound was just asked for, if any; otherwise the first
// one already audible keeps it. Silenced panels get their own button
// updated too, so what is shown never disagrees with what is heard.
const limitAudibleToOne = function(prefer) {
if (!audioIsExclusive) return;
const slide = slidesByIndex.get(state.feedActiveIndex);
if (!slide) return;
let kept = (prefer && prefer._muted === false) ? prefer : null;
panesOf(slide).forEach((pane) => {
if (pane._muted) return;
if (!kept) { kept = pane; return; }
if (pane === kept) return;
pane._muted = true;
if (pane._syncMute) pane._syncMute();
});
};
// Starts whatever in the current step is loaded but stopped -- which is
// what iOS leaves behind when a panel's sound is taken away from it.
const resumeActivePanes = function() {
const slide = slidesByIndex.get(state.feedActiveIndex);
if (!slide) return;
panesOf(slide).forEach((pane) => {
if (!pane.classList.contains('is-loaded')) return;
const video = pane.querySelector('.feed-video');
if (!video || !video.paused) return;
const playPromise = video.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
});
};
// Remembers playback position per video id so scrolling away and back
// resumes where the user left off. Slides kept in the window are merely
// paused (instant resume); slides whose <video> is torn down to free
@@ -684,6 +737,11 @@ App.feed = App.feed || {};
event.stopPropagation();
pane._muted = !pane._muted;
syncMute();
// Asking for this panel's sound gives up another's, where only one
// panel may have any -- and the one that lost it may have been
// stopped by the system on the way, so it is started again.
limitAudibleToOne(pane);
resumeActivePanes();
refreshFeedMuteState();
// The panel you can hear is the one that should follow you out.
updateAutoPiPTarget();
@@ -952,6 +1010,9 @@ App.feed = App.feed || {};
const activeSlide = slidesByIndex.get(clamped);
if (activeSlide) {
// Settled before anything starts: a second panel starting with
// sound is exactly what stops the first one (see audioIsExclusive).
limitAudibleToOne();
panesOf(activeSlide).forEach((pane) => {
loadSlideSource(pane, pane._videoData, true);
requestAnimationFrame(() => measureFeedTitle(pane));
@@ -1548,6 +1609,10 @@ App.feed = App.feed || {};
if (pane._syncMute) pane._syncMute();
});
});
// Unmuting everything is still one panel's worth of sound where the
// device allows only one; the rest keep the picture.
limitAudibleToOne();
resumeActivePanes();
App.feed.updateMuteButton();
};

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

179
tests/smoke_ios_split.py Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Split reels on iOS: every panel keeps playing, one panel keeps the sound.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_ios_split.py
There is no iPhone here, and the bug was never about what the video element
does once asked -- it is about what iOS does to the *other* video when one
starts with sound. So the rule is installed into Chromium and the real feed is
driven through it: unmuting a panel used to unmute every panel, and the system
would then stop all but one of them.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
IPHONE_UA = ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1")
# iOS's rule, near enough: one audible video at a time. Whatever starts (or
# unmutes) with sound takes the audio session, and whatever had it is paused.
AS_IOS = """(() => {
window.__systemPaused = [];
const claimAudio = (winner) => {
document.querySelectorAll('video').forEach((other) => {
if (other === winner || other.paused || other.muted) return;
window.__systemPaused.push(other.currentSrc || 'video');
HTMLMediaElement.prototype.pause.call(other);
});
};
const play = HTMLMediaElement.prototype.play;
HTMLMediaElement.prototype.play = function() {
if (!this.muted) claimAudio(this);
return play.apply(this, arguments);
};
const muted = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
Object.defineProperty(HTMLMediaElement.prototype, 'muted', {
configurable: true,
get() { return muted.get.call(this); },
set(value) {
muted.set.call(this, value);
if (!value && !this.paused) claimAudio(this);
},
});
})();"""
PANES = """() => {
const slide = document.querySelector('.feed-slide.is-active');
if (!slide) return null;
const panes = Array.from(slide.querySelectorAll('.feed-pane'));
return panes.map((pane) => {
const video = pane.querySelector('.feed-video');
const btn = pane.querySelector('.feed-pane-mute');
return {
muted: video ? video.muted : null,
flag: pane._muted === undefined ? null : !!pane._muted,
paused: video ? video.paused : null,
loaded: pane.classList.contains('is-loaded'),
button: btn ? btn.textContent : '',
};
});
}"""
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 open_reels(page):
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate("""([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}""", [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(1500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(4000)
def main():
c = Checks()
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",
])
context = browser.new_context(
viewport={"width": 430, "height": 930},
user_agent=IPHONE_UA, is_mobile=True, has_touch=True,
device_scale_factor=3)
context.add_init_script(AS_IOS)
page = context.new_page()
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
open_reels(page)
print("\nthe rule is really in force")
c.ok("the page says it is an iPhone",
"iPhone" in page.evaluate("() => navigator.userAgent"))
print("\nsplit in two, with sound on")
page.click(".feed-slide.is-active .feed-pane .feed-pane-split-right")
page.wait_for_timeout(5000)
page.evaluate("() => App.feed.toggleMute()") # the feed-wide unmute
page.wait_for_timeout(3000)
panes = page.evaluate(PANES)
c.ok("there are two panels", panes and len(panes) == 2, str(panes))
audible = [p for p in panes if p["muted"] is False]
c.ok("exactly one of them has sound", len(audible) == 1, str(panes))
c.ok("and the other says so on its own button",
all(p["button"] == "🔇" for p in panes if p["muted"]), str(panes))
c.ok("the flags agree with the elements",
all(p["flag"] == p["muted"] for p in panes), str(panes))
c.ok("both panels are playing",
all(p["paused"] is False for p in panes if p["loaded"]), str(panes))
print("\nmoving the sound to the other panel")
page.click(".feed-slide.is-active .feed-pane:last-of-type .feed-pane-mute")
page.wait_for_timeout(2500)
moved = page.evaluate(PANES)
c.ok("the panel asked for has it", moved[-1]["muted"] is False, str(moved))
c.ok("the first one gave it up", moved[0]["muted"] is True, str(moved))
c.ok("and nothing stopped playing",
all(p["paused"] is False for p in moved if p["loaded"]), str(moved))
print("\nswiping on")
page.evaluate("() => { const s = document.getElementById('feed-scroll');"
" s.scrollTop += s.clientHeight; }")
page.wait_for_timeout(5000)
stepped = page.evaluate(PANES)
c.ok("the next step plays in both panels",
stepped and all(p["paused"] is False for p in stepped if p["loaded"]),
str(stepped))
c.ok("still only one audible", len([p for p in stepped if p["muted"] is False]) <= 1,
str(stepped))
# Without the fix, both panels would be unmuted -- so prove the rule
# installed above actually bites, by breaking it on purpose.
print("\nthe simulated rule is what the fix is for")
page.evaluate("""() => {
document.querySelectorAll('.feed-slide.is-active .feed-video').forEach((v) => {
v.muted = false;
const p = v.play();
if (p && p.catch) p.catch(() => {});
});
}""")
page.wait_for_timeout(1500)
forced = page.evaluate(PANES)
c.ok("unmuting every panel does stop one of them",
any(p["paused"] for p in forced if p["loaded"]), str(forced))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
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())

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())