Skip to content

Video Tap Controls Not Reappearing After Disappearing

Metadata

  • Date: 2026-05-08
  • Status: fixed
  • Severity: high
  • Related issue/ticket: PR #427
  • Owner: Benjamin Lewis

About

Overview: - After video controls auto-hide (3 second timer), tapping the screen does nothing — controls never reappear. - PR #427 added a TapGestureHandler (RNGH legacy v1 API) to memory-viewer-modal.tsx to toggle control visibility. In practice, taps do not fire.

Technical Questions: - Is GestureHandlerRootView missing from the app root? No — _layout.tsx wraps the entire app in GestureHandlerRootView. - Is the RNGH version too old for TapGestureHandler? No — react-native-gesture-handler ~2.28.0 supports it. - Does TapGestureHandler (old API) work reliably over a native VideoView? No. The native video surface (expo-video VideoView) renders as a native view at the OS level. On both Android and iOS, the native video player surface consumes touch events before they propagate to the JS gesture recognizer, causing TapGestureHandler.onActivated to never fire. - Is there a secondary logic bug? Yes. handleVideoTap reads controlsVisible from a stale closure when deciding whether to call showControlsBriefly(). The functional updater to setControlsVisible inverts the value correctly, but the branch that follows reads the pre-toggle value — meaning every other tap calls the wrong branch.

Resources: - main/app/components/memory/memory-viewer-modal.tsxVideoPlayer component with TapGestureHandler - main/app/app/_layout.tsx — app root with GestureHandlerRootView - main/app/__tests__/memory-viewer-modal.test.tsx — existing tests

Steps to cause failure

flowchart LR
  VideoPlays["Video plays, controls visible"] --> Timer["showControlsBriefly 3s timer fires"]
  Timer --> HideControls["setControlsVisible(false) — controls hidden"]
  HideControls --> UserTap["User taps screen"]
  UserTap --> NativeSurface["Native VideoView surface consumes touch at OS level"]
  NativeSurface --> JSGestureDropped["TapGestureHandler.onActivated never called"]
  JSGestureDropped --> NoReappear["Controls remain hidden indefinitely"]

System

flowchart TD
  TGH[TapGestureHandler] --> View[View videoContainer]
  View --> VideoView[expo-video VideoView\nnativeControls=false]
  VideoView -->|touch captured by native layer| Lost[Touch event lost\nbefore JS gesture recognizer]
  TGH -->|onActivated never fires| handleVideoTap[handleVideoTap]

Reproduction Details

  1. Open a visual memory in the viewer modal
  2. The video loads and controls appear (play/pause, scrubber, mute)
  3. After 3 seconds, controls auto-hide
  4. Tap anywhere on the video — controls do not reappear
  5. There is no way to regain controls or pause the video via tap

Reproduction test: main/app/__tests__/memory-viewer-modal.test.tsx"tapping video toggles controls visibility"

Notes for PR

Root cause 1 (primary): TapGestureHandler from the RNGH legacy API does not receive touch events that pass through a native VideoView. The native video surface is rendered by the OS-level player (not React Native), and on both Android and iOS it intercepts touch events before they reach the JS gesture recognizer. The correct fix is to replace TapGestureHandler with a Pressable wrapper placed as a sibling/overlay over the video, using pointerEvents="box-only" so it receives taps but does not block the underlying Pressable controls.

Root cause 2 (secondary): handleVideoTap has a stale closure bug. It reads controlsVisible to decide which branch to take after the functional updater has already toggled it. Since setControlsVisible(v => !v) is asynchronous, controlsVisible still holds the old value during the same synchronous execution. The branch logic should be derived from the pre-toggle value (which is what controlsVisible holds), making it technically correct in intent — but the real fix is to simplify: just call showControlsBriefly() unconditionally when showing controls, and refactor to avoid the dual-path confusion.

Fix strategy: 1. Remove TapGestureHandler and its import. 2. Add a Pressable with style={StyleSheet.absoluteFill} and pointerEvents="box-only" as an overlay over the VideoView, below the controls overlay in z-order. 3. Simplify handleVideoTap: toggle with functional updater, always show briefly when transitioning to visible.

Audit Log

ID Action Note Context
1 Create audit log Initialize bug investigation Tapping video after controls hide does nothing
2 Read memory-viewer-modal.tsx TapGestureHandler wraps View that contains VideoView and controls overlay RNGH ~2.28.0 legacy API
3 Read _layout.tsx GestureHandlerRootView present at app root — not the issue Confirmed
4 Check package.json react-native-gesture-handler ~2.28.0 Version fine, API issue
5 Identify root cause 1 TapGestureHandler does not receive events through native VideoView OS surface Native view intercepts touches
6 Identify root cause 2 handleVideoTap reads stale controlsVisible closure value Functional updater toggles async
7 Write failing test Test verifies tapping video toggles controls; fails with TapGestureHandler mock Before fix
8 Confirm test fails Test fails as expected Confirmed pre-fix failure
9 Apply fix Replace TapGestureHandler with Pressable overlay; simplify handleVideoTap Root cause addressed
10 Confirm test passes Test passes after fix Confirmed

Verification

  • [x] Reproduced failure before fix (test for video-tap-area testID fails — element doesn't exist with TapGestureHandler)
  • [x] Reproduction test fails before fix (new test tapping video area toggles controls visibility via Pressable overlay)
  • [x] Root cause identified with evidence (native VideoView surface intercepts OS-level touches before JS gesture recognizer; confirmed by RNGH architecture)
  • [x] Fix applied at source (replaced TapGestureHandler with Pressable overlay; moved timer logic inside functional updater)
  • [x] Reproduction test passes after fix (all 6 tests pass)
  • [x] Regression test added/updated (tapping video area toggles controls visibility via Pressable overlay in memory-viewer-modal.test.tsx)
  • [x] Verified no duplicate solved-bug log exists for same root cause