Keep the videos already playing when a panel is split

Splitting or closing a panel changes the stride, which renumbers every
step, so the slides are rebuilt -- and the rebuild threw away the panes
along with them. Every video on screen was fetched and buffered again to
show what it was already showing.

The panes are now set aside by video id first, and whoever needs that
video next takes the pane that already has it: the same element, the
same stream, the frames it had already decoded. It all happens in one go
without yielding, so a pane is never out of the document long enough to
be paused for it. Panes the new layout has no place for are torn down as
before -- they are holding a stream open.

Pane tools are rebindable for the same reason: a reused pane comes back
under a different node in the tree, and its old buttons would otherwise
split and close the one it left.

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-22 12:14:16 +00:00
parent 8163693150
commit adb254c20b
2 changed files with 412 additions and 6 deletions

348
tests/smoke_split_reuse.py Normal file
View File

@@ -0,0 +1,348 @@
#!/usr/bin/env python3
"""Splitting a panel keeps the video that is already playing, rather than
fetching it again.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_split_reuse.py
The clip server counts what is asked of it, which is the whole point: a split
renumbers every step and rebuilds the slides, and the question is whether the
bytes already in hand survive that. The listing is a fixture for the same
reason as everywhere else -- so the answer is about the feed, not a CDN.
Each panel's <video> is tagged, so a rebuilt panel is told apart from a reused
one by the element rather than by what it is showing.
"""
import base64
import http.server
import json
import socketserver
import sys
import threading
import time
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
PER_PAGE = 12
PAGES = 8
MEDIA_DELAY_MS = 300 # what a clip costs if it isn't already loading
# One second of black, 64x64, h264 -- a real file, because the panels have to
# decode a frame from it. Same blob as smoke_open.py.
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(fetched):
"""Serves the fixture clip, counting who asked for what."""
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def do_GET(self):
fetched[self.path] = fetched.get(self.path, 0) + 1
time.sleep(MEDIA_DELAY_MS / 1000)
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)
def do_HEAD(self):
fetched[self.path] = fetched.get(self.path, 0) + 1
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([]));
}"""
# Which videos were asked to resolve their formats. Prefilled formats make the
# answer immediate, but the asking is what says when the feed goes looking.
SPY = """() => {
window.__resolved = [];
const real = App.videos.ensureFormats;
App.videos.ensureFormats = function(video) {
if (video && video.id) window.__resolved.push(String(video.id));
return real.apply(this, arguments);
};
}"""
PANES = """() => Array.from(document.querySelectorAll('.feed-slide')).map((slide) => ({
step: Number(slide.dataset.step),
loaded: Array.from(slide.querySelectorAll('.feed-pane'))
.map((pane) => pane.classList.contains('is-loaded')),
ids: Array.from(slide.querySelectorAll('.feed-pane')).map((pane) => pane.dataset.videoId),
}))"""
WATCH = """() => {
window.__t0 = performance.now();
window.__started = {};
document.addEventListener('playing', (e) => {
const pane = e.target.closest && e.target.closest('.feed-pane');
if (!pane) return;
const id = pane.dataset.videoId;
if (!(id in window.__started)) window.__started[id] = Math.round(performance.now() - window.__t0);
}, true);
}"""
SWIPE = """() => { const s = document.getElementById('feed-scroll'); s.scrollTop += s.clientHeight; }"""
CLICK = """(sel) => {
const el = document.querySelector(sel);
if (!el) return 'missing';
el.click(); // dispatched, so the pointer never wakes the HUD
return 'ok';
}"""
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 make_items(page_no, media):
"""One page of the listing, formats already resolved so nothing but the
media itself stands between a panel and its first frame."""
start = (page_no - 1) * PER_PAGE
items = []
for i in range(PER_PAGE):
n = start + i
url = f"{media}/v{n}.mp4"
items.append({
"id": f"fix-{n}",
"title": f"Fixture {n}",
"url": "https://example.test/watch/%d" % n,
"channel": "test",
"duration": 1,
"thumb": "",
"tags": [],
"meta": {
"url": url,
"http_headers": {},
"isLive": False,
"formats": [{"url": url, "ext": "mp4", "height": 240, "protocol": "https",
"vcodec": "avc1", "acodec": "none"}],
},
})
return items
# What a panel is holding: the element itself (tagged), where it has got to,
# and whether it still has its source.
MARK = """() => {
document.querySelectorAll('.feed-pane').forEach((pane, i) => {
const video = pane.querySelector('.feed-video');
if (video && !video.__tag) video.__tag = 'v' + i + '-' + pane.dataset.videoId;
});
}"""
PANELS = """() => Array.from(document.querySelectorAll('.feed-slide.is-active .feed-pane')).map((pane) => {
const video = pane.querySelector('.feed-video');
return {
id: pane.dataset.videoId,
tag: video ? (video.__tag || null) : null,
t: video ? Math.round(video.currentTime * 100) / 100 : null,
paused: video ? video.paused : null,
buffered: video && video.buffered.length ? Math.round(video.buffered.end(0) * 100) / 100 : 0,
ready: video ? video.readyState : -1,
loaded: pane.classList.contains('is-loaded'),
};
})"""
CLICK = """(sel) => {
const el = document.querySelector(sel);
if (!el) return 'missing';
el.click();
return 'ok';
}"""
def main():
c = Checks()
fetched = {}
httpd = make_server(fetched)
media = f"http://127.0.0.1:{httpd.server_address[1]}"
parked = []
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=768",
])
page = browser.new_page(viewport={"width": 800, "height": 900})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
def answer():
while parked:
route = parked.pop(0)
try:
body = json.loads(route.request.post_data or "{}")
except Exception:
body = {}
page_no = int(body.get("page", 1) or 1)
route.fulfill(status=200, content_type="application/json", body=json.dumps({
"items": make_items(page_no, media) if page_no <= PAGES else [],
"pageInfo": {"hasNextPage": page_no < PAGES},
}))
def settle(ms):
for _ in range(max(1, ms // 100)):
page.wait_for_timeout(100)
answer()
page.route("**/api/videos", lambda route: parked.append(route))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
settle(1500)
page.wait_for_selector(".video-card", timeout=30000)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
settle(4000)
page.evaluate(MARK)
before = page.evaluate(PANELS)
watched = before[0]["id"]
print(f" (watching {watched}, at {before[0]['t']}s)")
c.ok("one panel to start with", len(before) == 1, str(before))
c.ok("and it is playing", before[0]["paused"] is False, str(before[0]))
asked_before = dict(fetched)
print("\nsplitting it in two")
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane .feed-pane-split-right")
settle(2500)
after = page.evaluate(PANELS)
kept = [pane for pane in after if pane["id"] == watched]
print(f" (panels now: {[(x['id'], x['tag'], x['t']) for x in after]})")
c.ok("there are two panels", len(after) == 2, str(after))
c.ok("the video being watched is still one of them", len(kept) == 1, str(after))
c.ok("in the same element it was playing in -- not a new one",
kept and kept[0]["tag"] == before[0]["tag"], f"{kept and kept[0]['tag']} vs {before[0]['tag']}")
# Not currentTime: the fixture clip is a second long and the feed loops
# it, so where it has got to says nothing. What says it never reloaded
# is that it still has decoded frames and a buffer.
c.ok("with the frames it had already decoded, not back at nothing",
kept and kept[0]["ready"] >= 2 and kept[0]["buffered"] > 0, str(kept))
c.ok("and it is still playing", kept and kept[0]["paused"] is False, str(kept))
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
watched_path = "/v0.mp4"
c.ok("its bytes were not fetched a second time",
watched_path not in again,
f"asked for again: {sorted(again)}")
print("\nand splitting once more")
page.evaluate(MARK) # tag first, then read: an untagged pane
marks = page.evaluate(PANELS) # on both sides would compare None to None
asked_before = dict(fetched)
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(1) .feed-pane-split-down")
settle(2500)
three = page.evaluate(PANELS)
held = {pane["id"]: pane for pane in three}
c.ok("three panels now", len(three) == 3, str(three))
expected = [m for m in marks if m["id"] in held]
carried = [m for m in expected if held[m["id"]]["tag"] == m["tag"]]
c.ok("both panels that were already playing carried their element over",
expected and all(m["tag"] for m in expected) and len(carried) == len(expected),
str(three))
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
c.ok("and neither was fetched again",
not any(f"/v{i}.mp4" in again for i in (0, 1)), f"asked for again: {sorted(again)}")
print("\nclosing one of them")
asked_before = dict(fetched)
page.evaluate(MARK)
marks = page.evaluate(PANELS)
page.evaluate(CLICK, ".feed-slide.is-active .feed-pane:nth-of-type(2) .feed-pane-close")
settle(2500)
left = page.evaluate(PANELS)
held = {pane["id"]: pane for pane in left}
c.ok("two panels left", len(left) == 2, str(left))
survivors = [m for m in marks if m["id"] in held]
c.ok("the survivors kept their elements",
survivors and all(m["tag"] and held[m["id"]]["tag"] == m["tag"] for m in survivors),
str(left))
c.ok("still playing", all(pane["paused"] is False for pane in left), str(left))
again = [path for path, n in fetched.items() if n > asked_before.get(path, 0)]
c.ok("and nothing that was already loaded was fetched again",
not any(f"/v{i}.mp4" in again for i in (0, 1, 2)), f"asked for again: {sorted(again)}")
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())