diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 33eb386..b699e40 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -92,6 +92,21 @@ App.feed = App.feed || {}; 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 root = getRoot(); return root ? root.querySelector(`#${id}`) : null; @@ -287,6 +302,8 @@ App.feed = App.feed || {}; timeline.addEventListener('pointerdown', (event) => { scrubbing = true; + // Dragging the scrubber is the one drag that *is* the HUD. + wakeHud(); timeline.classList.add('is-scrubbing'); timeline.setPointerCapture(event.pointerId); seekFromPointer(slide, video, timeline, event.clientX); @@ -296,6 +313,7 @@ App.feed = App.feed || {}; timeline.addEventListener('pointermove', (event) => { if (!scrubbing) return; + wakeHud(); seekFromPointer(slide, video, timeline, event.clientX); event.preventDefault(); event.stopPropagation(); @@ -1153,7 +1171,6 @@ App.feed = App.feed || {}; }; const onScroll = function() { - wakeHud(); if (scrollRaf) return; scrollRaf = requestAnimationFrame(() => { scrollRaf = null; @@ -1162,6 +1179,7 @@ App.feed = App.feed || {}; // Ignore scroll events fired by a resize/orientation re-snap; the // active video is realigned by onResize instead (see suppressScroll). if (suppressScroll) return; + sleepHud(); peekIncoming(scroller); if (peekSettleTimer) clearTimeout(peekSettleTimer); peekSettleTimer = setTimeout(settlePeek, PEEK_SETTLE_MS); @@ -1646,8 +1664,18 @@ App.feed = App.feed || {}; if (!hudActivityBound) { container.addEventListener('mousemove', wakeHud, { passive: true }); - container.addEventListener('pointerdown', wakeHud, { passive: true }); - container.addEventListener('touchstart', wakeHud, { passive: true }); + // A press on the video is how a swipe begins, and waking on that + // 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; } @@ -1666,7 +1694,10 @@ App.feed = App.feed || {}; App.feed.updateToggleButton(); 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() { diff --git a/tests/smoke_hud.py b/tests/smoke_hud.py new file mode 100644 index 0000000..781965d --- /dev/null +++ b/tests/smoke_hud.py @@ -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())