visar.log
Technical notes from building things
← all posts

From Tap Quadrants to Swipe Gestures in a Hostile DOM

The Starting Point

Tango Explorer is a userscript that takes over a live streaming page. It kills the original UI, blocks the page’s timers and event listeners, and renders its own player with a list of streams. Navigation between streams was handled by an invisible quadrant overlay — a 3x3 CSS grid layered over the video. Tap top-left for previous, top-right for next, bottom for toggle UI.

It worked, but it was clunky. No visual feedback, easy to mis-tap, and the bottom third of the screen was wasted on a toggle. The video editor project already had a swipe gesture system that felt native on iOS. Time to port it.

The addEventListener Problem

The userscript aggressively blocks the page’s event system to prevent Tango’s own JS from interfering:

const noisyEvents = ["scroll", "mousemove", "resize", "touchmove", "touchstart"];
const originalAddEventListener = EventTarget.prototype.addEventListener;
EventTarget.prototype.addEventListener = function (type, listener, options) {
    if (noisyEvents.includes(type)) return;
    return originalAddEventListener.call(this, type, listener, options);
};

This replaces addEventListener globally. Any code — Tango’s or ours — that calls element.addEventListener('touchstart', ...) gets silently dropped.

The first attempt was to just remove touchmove and touchstart from the blocked list. That works for our handlers, but also lets the page’s own touch handlers through. The page’s JS could start fighting our gesture system.

The fix: keep touch events blocked globally, but save the original addEventListener and pass it through the dependency chain. Our code uses the original directly:

const listen = this.originalAddEventListener;
listen.call(container, 'touchstart', (e: TouchEvent) => { ... });
listen.call(container, 'touchmove', (e: TouchEvent) => { ... }, { passive: false });

The page’s code calls the patched version and gets nothing. Our code calls the real one and gets everything.

The Gesture System

Three gestures, no seek (live streams only):

  • Vertical swipe past 80px threshold: navigate between streamers. Swipe up for next, down for previous.
  • Horizontal swipe past 80px threshold: show or hide the UI controls. Right to show, left to hide.
  • Edge-back swipe starting within 30px of the left edge: go back to the stream list. Follows the finger with translateX, commits at 30% screen width.

Axis detection uses a 10px dead zone. Once the axis is locked (horizontal vs vertical), it stays locked for the entire gesture. Multi-touch is ignored — zoom is disabled via viewport meta, so there’s no need for debounce.

The Edge-Back Visual Problem

The edge-back swipe slides #videoView to the right using translateX. In the video editor, this naturally reveals the list underneath because both views coexist in the DOM. In the userscript, the list view uses display: none when in video mode — crucial for iOS to allow transparent status bars.

Sliding the video view right revealed nothing but black.

The fix: when the edge-back gesture is detected, immediately remove the hidden-view class from the list view so it’s visible underneath. If the swipe is cancelled (finger released before 30% threshold), add the class back. If committed, the SHOW_LIST event handles the proper view mode transition.

if (this.swipeStartX <= EDGE_ZONE && dx > 0) {
    this.swipeType = 'edge-back';
    this.getVideoView().classList.add('swipe-active');
    this.getListView().classList.remove('hidden-view');
}

The animation uses CSS transitions — transition: transform 250ms ease-out is added via a swipe-animating class only during the commit/cancel phase. During the drag, the transform follows the finger directly with no transition. A transitionend listener handles cleanup, avoiding the need for setTimeout (which is patched and would be dropped).

Other Changes

  • Text selectable: removed user-select: none from the video view. Streamer names can now be selected and copied.
  • Heart icons: follow/unfollow buttons changed from ➕/➖ to 🤍/❤️. Red border on the button when following.
  • Back button removed: replaced entirely by the edge-back swipe.
  • Zoom disabled: viewport meta now includes maximum-scale=1, user-scalable=no, which also made the multi-touch debounce unnecessary in both this project and the video editor.

Takeaway

When you control the page’s event system, you have to be deliberate about who gets access to what. A global addEventListener override is a blunt instrument — it blocks everything, including your own code. Saving the original reference and threading it through dependency injection gives you a clean separation: the page is sandboxed, your code is not.