Turn document · 2026-05-25 05:28 EDT

# Fix Native Player Controls Tap-to-Show

Native player controls can now be brought back after they auto-hide or are hidden by tapping the player. The fix gives player taps a reliable full-screen gesture surface above the VLC video view while keeping visible controls interactive.

Issue: dreamio-wgk File: Dreamio/NativePlayerViewController.swift Validation: Xcode build passed

## Summary

Fixed the native playback overlay so hidden controls are not effectively gone forever. A transparent tap surface now receives taps over the video, and hidden control views stop intercepting touches until they are visible again.

## Changes Made

- Added a full-screen transparent `tapSurfaceView` above the VLC drawable and below the loading, failure, controls, and close-button layers.
- Moved the tap gesture recognizer from the root view to that tap surface so player taps are handled consistently.
- Disabled user interaction on the controls container and close button while they are hidden, then re-enabled it when controls are revealed.

## Context

The native player uses MobileVLCKit for video rendering and an overlay built in UIKit for playback controls. Before this change, the gesture recognizer was attached to the root view. Once controls faded out, the visible controls had alpha zero but still occupied their layout area, and the video drawable could also interfere with root-level tap handling. That left some taps with no route back to `revealControls()`.

## Important Implementation Details

The tap surface is inserted immediately after `backend.view`, which keeps it above the video but below the actual controls. This preserves normal button and slider behavior when controls are visible while making the rest of the player a reliable tap target.

When `hideControls()` runs, the controls and close button are also made non-interactive. This matters because alpha-zero UIKit views can still participate in hit testing unless interaction is disabled or the views are hidden.

## Relevant Diff Snippets

The diff below is rendered with `@pierre/diffs/ssr` using `preloadPatchDiff`.

Dreamio/NativePlayerViewController.swift
-1+18

    35 unmodified lines363738394041125 unmodified lines1671681691701711729 unmodified lines18218318418518618718819 unmodified lines208209210211212213124 unmodified lines3383393403413423432 unmodified lines34634734834935035135 unmodified lines        return view    }()
        private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")    private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")    private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")125 unmodified lines
        private func configureLayout() {        view.addSubview(backend.view)        view.addSubview(loadingView)        view.addSubview(failureLabel)        view.addSubview(controlsContainer)9 unmodified lines
            let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))        tap.cancelsTouchesInView = false        view.addGestureRecognizer(tap)
            let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])        controlRow.translatesAutoresizingMaskIntoConstraints = false19 unmodified lines            backend.view.topAnchor.constraint(equalTo: view.topAnchor),            backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),            loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    124 unmodified lines    }
        private func revealControls() {        UIView.animate(withDuration: 0.18) {            self.controlsContainer.alpha = 1            self.closeButton.alpha = 12 unmodified lines    }
        private func hideControls() {        UIView.animate(withDuration: 0.24) {            self.controlsContainer.alpha = 0            self.closeButton.alpha = 035 unmodified lines36373839404142434445464748125 unmodified lines1741751761771781791809 unmodified lines19019119219319419519619 unmodified lines216217218219220221222223224225226124 unmodified lines3513523533543553563573582 unmodified lines36136236336436536636736835 unmodified lines        return view    }()
        private let tapSurfaceView: UIView = {        let view = UIView()        view.translatesAutoresizingMaskIntoConstraints = false        view.backgroundColor = .clear        return view    }()
        private let playPauseButton = NativePlayerViewController.iconButton(systemName: "pause.fill", label: "Play or Pause")    private let jumpBackButton = NativePlayerViewController.iconButton(systemName: "gobackward.15", label: "Jump Back 15 Seconds")    private let jumpForwardButton = NativePlayerViewController.iconButton(systemName: "goforward.15", label: "Jump Forward 15 Seconds")125 unmodified lines
        private func configureLayout() {        view.addSubview(backend.view)        view.addSubview(tapSurfaceView)        view.addSubview(loadingView)        view.addSubview(failureLabel)        view.addSubview(controlsContainer)9 unmodified lines
            let tap = UITapGestureRecognizer(target: self, action: #selector(toggleControlsVisibility))        tap.cancelsTouchesInView = false        tapSurfaceView.addGestureRecognizer(tap)
            let controlRow = UIStackView(arrangedSubviews: [jumpBackButton, playPauseButton, jumpForwardButton, captionsButton])        controlRow.translatesAutoresizingMaskIntoConstraints = false19 unmodified lines            backend.view.topAnchor.constraint(equalTo: view.topAnchor),            backend.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                tapSurfaceView.leadingAnchor.constraint(equalTo: view.leadingAnchor),            tapSurfaceView.trailingAnchor.constraint(equalTo: view.trailingAnchor),            tapSurfaceView.topAnchor.constraint(equalTo: view.topAnchor),            tapSurfaceView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
                loadingView.centerXAnchor.constraint(equalTo: view.centerXAnchor),            loadingView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
    124 unmodified lines    }
        private func revealControls() {        controlsContainer.isUserInteractionEnabled = true        closeButton.isUserInteractionEnabled = true        UIView.animate(withDuration: 0.18) {            self.controlsContainer.alpha = 1            self.closeButton.alpha = 12 unmodified lines    }
        private func hideControls() {        controlsContainer.isUserInteractionEnabled = false        closeButton.isUserInteractionEnabled = false        UIView.animate(withDuration: 0.24) {            self.controlsContainer.alpha = 0            self.closeButton.alpha = 0

## Expected Impact for End-Users

Users should be able to tap the native player to hide controls and tap the video again to bring them back. Auto-hidden controls should behave the same way, so playback is no longer trapped in a controls-hidden state.

## Validation

Passed: `xcodebuild build -workspace Dreamio.xcworkspace -scheme Dreamio -destination 'generic/platform=iOS'`

The build succeeded for the Dreamio scheme against a generic iOS destination. Manual on-device interaction was not run in this turn, so the remaining risk is limited to real touch behavior across physical device sizes.

## Issues, Limitations, and Mitigations

- The fix is intentionally scoped to tap routing and hidden overlay hit testing. It does not change VLC playback state, seeking, captions, or close behavior.
- Manual device testing is still useful because UIKit gesture delivery around embedded native video surfaces can vary with presentation details.
- The Xcode build reports an existing warning that the MobileVLCKit preparation script has no declared outputs. This was not introduced by the tap fix.

## Follow-up Work

- No new follow-up issue is required for this fix.
- Optional future improvement: add an injectable player overlay test harness so tap-to-show behavior can be exercised without launching MobileVLCKit on a device.