Leave the controls down while swiping through reels

Scrolling woke the HUD, so a swipe to the next reel put the controls up
over the video arriving -- the one thing the swipe was for -- and left them
there for a second. The scroll now puts them away instead.

Removing that alone wasn't enough: the container also woke on pointerdown
and touchstart, which is how every drag begins, so the HUD still flashed at
the start of one. A press only wakes them now if it lands on a control --
which is a press on something the reader can't see while they're hidden,
and never the start of a swipe. A press that turns out to be a tap still
wakes them through the gesture layer's onSingleTap, the mouse moving still
wakes them, and dragging the scrubber keeps them up, being the one drag
that is the HUD.

Two details follow from it. The sleep sits after onScroll's suppressScroll
guard, so a resize or a picture-in-picture re-snap doesn't count as the
reader moving. And opening the feed scrolls to the starting step, whose
scroll event would put the controls straight back down, so that wake waits
a frame -- scroll events run before animation frames.

The checks live in their own file rather than in smoke_reels: one panel and
one swipe is all they need, and the split-panel suite is the heaviest thing
in the tree to run.

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 17:57:04 +00:00
parent 39a47289b9
commit 741786c7cf
2 changed files with 159 additions and 4 deletions

View File

@@ -92,6 +92,21 @@ App.feed = App.feed || {};
if (state.feedOpen) scheduleHudHide(); if (state.feedOpen) scheduleHudHide();
}; };
// Moving through the feed is not using the HUD. A swipe to the next reel
// used to put the controls up over the video arriving -- the one thing the
// swipe was for -- and leave them there for a second. So the scroll puts
// them away instead, and only a real interaction (a tap, the mouse moving,
// a control being used) brings them back.
const sleepHud = function() {
if (hudIdleTimer) {
clearTimeout(hudIdleTimer);
hudIdleTimer = null;
}
setHudIdle(true);
const root = getRoot();
if (root) root.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
};
const inRoot = function(id) { const inRoot = function(id) {
const root = getRoot(); const root = getRoot();
return root ? root.querySelector(`#${id}`) : null; return root ? root.querySelector(`#${id}`) : null;
@@ -287,6 +302,8 @@ App.feed = App.feed || {};
timeline.addEventListener('pointerdown', (event) => { timeline.addEventListener('pointerdown', (event) => {
scrubbing = true; scrubbing = true;
// Dragging the scrubber is the one drag that *is* the HUD.
wakeHud();
timeline.classList.add('is-scrubbing'); timeline.classList.add('is-scrubbing');
timeline.setPointerCapture(event.pointerId); timeline.setPointerCapture(event.pointerId);
seekFromPointer(slide, video, timeline, event.clientX); seekFromPointer(slide, video, timeline, event.clientX);
@@ -296,6 +313,7 @@ App.feed = App.feed || {};
timeline.addEventListener('pointermove', (event) => { timeline.addEventListener('pointermove', (event) => {
if (!scrubbing) return; if (!scrubbing) return;
wakeHud();
seekFromPointer(slide, video, timeline, event.clientX); seekFromPointer(slide, video, timeline, event.clientX);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -1153,7 +1171,6 @@ App.feed = App.feed || {};
}; };
const onScroll = function() { const onScroll = function() {
wakeHud();
if (scrollRaf) return; if (scrollRaf) return;
scrollRaf = requestAnimationFrame(() => { scrollRaf = requestAnimationFrame(() => {
scrollRaf = null; scrollRaf = null;
@@ -1162,6 +1179,7 @@ App.feed = App.feed || {};
// Ignore scroll events fired by a resize/orientation re-snap; the // Ignore scroll events fired by a resize/orientation re-snap; the
// active video is realigned by onResize instead (see suppressScroll). // active video is realigned by onResize instead (see suppressScroll).
if (suppressScroll) return; if (suppressScroll) return;
sleepHud();
peekIncoming(scroller); peekIncoming(scroller);
if (peekSettleTimer) clearTimeout(peekSettleTimer); if (peekSettleTimer) clearTimeout(peekSettleTimer);
peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS); peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS);
@@ -1646,8 +1664,18 @@ App.feed = App.feed || {};
if (!hudActivityBound) { if (!hudActivityBound) {
container.addEventListener('mousemove', wakeHud, { passive: true }); container.addEventListener('mousemove', wakeHud, { passive: true });
container.addEventListener('pointerdown', wakeHud, { passive: true }); // A press on the video is how a swipe begins, and waking on that
container.addEventListener('touchstart', wakeHud, { passive: true }); // flashed the HUD at the start of every drag -- a press that turns
// out to be a tap wakes it through the gesture layer's onSingleTap
// instead. A press on a control is not a swipe, and the control it
// lands on is one the reader can't see while the HUD is hidden.
container.addEventListener('pointerdown', (event) => {
const target = event.target;
if (target && target.closest &&
target.closest('button, input, a, .feed-timeline, .feed-format-menu')) {
wakeHud();
}
}, { passive: true });
hudActivityBound = true; hudActivityBound = true;
} }
@@ -1666,7 +1694,10 @@ App.feed = App.feed || {};
App.feed.updateToggleButton(); App.feed.updateToggleButton();
App.feed.updateMuteButton(); App.feed.updateMuteButton();
wakeHud(); // The scroll assignment above fires a scroll event, which puts the HUD
// away; the controls should be up when the feed opens, so this waits
// for that to land (scroll events run before animation frames).
requestAnimationFrame(wakeHud);
}; };
App.feed.close = function() { App.feed.close = function() {

124
tests/smoke_hud.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""The reels HUD: up when you're using it, down when you're swiping past it.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_hud.py
One panel and one swipe, deliberately: what's under test is when the controls
come and go, not anything about the layout.
The presses here are dispatched rather than clicked, because moving a mouse is
itself a reason to wake the HUD and would hide what is being asked.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottubapp.io"
CHANNEL = "xvideos"
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
}"""
IDLE = """() => document.getElementById('feed-view').classList.contains('feed-hud-idle')"""
PRESS = """(selector) => {
const el = document.querySelector('.feed-slide.is-active ' + selector);
if (!el) return 'missing';
el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
return document.getElementById('feed-view').classList.contains('feed-hud-idle');
}"""
SWIPE = """() => {
const s = document.getElementById('feed-scroll');
s.scrollTop += s.clientHeight;
}"""
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 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",
])
page = browser.new_page(viewport={"width": 430, "height": 930})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
try:
page.wait_for_selector(".video-card", timeout=90000)
except Exception:
# One reload, in case the status refresh or the listing failed.
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(2500)
page.evaluate("() => App.feed.open()")
page.wait_for_selector(".feed-slide", timeout=20000)
page.wait_for_timeout(400)
print("\nopening the feed")
c.ok("the controls are up to start with", page.evaluate(IDLE) is False)
print("\nleaving it alone")
page.wait_for_timeout(1600)
c.ok("they fade on their own", page.evaluate(IDLE) is True)
print("\nswiping to the next reel")
page.evaluate(SWIPE)
page.wait_for_timeout(300)
c.ok("the swipe does not put them back up mid-flight", page.evaluate(IDLE) is True)
page.wait_for_timeout(2500)
c.ok("nor once it has landed", page.evaluate(IDLE) is True)
c.ok("and the reel it landed on is playing",
page.evaluate("""() => {
const v = document.querySelector('.feed-slide.is-active .feed-video');
return !!v && !v.paused;
}"""))
print("\nwhat does bring them back")
c.ok("pressing the video -- which is how a swipe begins -- does not",
page.evaluate(PRESS, ".feed-video") is True)
c.ok("pressing a control does", page.evaluate(PRESS, ".feed-pane-mute") is False)
page.wait_for_timeout(1600)
c.ok("after which they fade again", page.evaluate(IDLE) is True)
page.mouse.move(200, 400)
page.mouse.move(210, 420)
page.wait_for_timeout(200)
c.ok("moving the mouse does too", page.evaluate(IDLE) is False)
print("\na swipe started while they are up")
c.ok("puts them away", (page.evaluate(SWIPE), page.wait_for_timeout(500),
page.evaluate(IDLE))[2] is True)
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())