If you set AVPlayerViewController.showsPlaybackControls to false, the controls will not show at all. Even if you tap the screen.
I want the controls to start out hid
I think I've solved this using dynamic gesture recognizer relationships. The solution avoids custom controls (for consistency), uses only public API and does not subclass AVPlayerViewController (which is explicitly disallowed, as noted in other answers).
Here's how:
Make a container view controller that embeds AVPlayerViewController. (This is useful regardless of the controls, because you need to put the playback logic somewhere.)
Set showsPlaybackControls to false initially.
Add a UITapGestureRecognizer to recognize the initial tap.
In the action method for the gesture recognizer, set showsPlaybackControls to true.
So far, it would work, but the controls would disappear immediately on that initial tap. To fix that, set yourself as a delegate for the gesture recognizer, implement gestureRecognizer:shouldBeRequiredToFailByGestureRecognizer: and return true for any other single-tap gesture recognizer.
Here's the actual implementation in Swift; check andreyvit/ModalMoviePlayerViewController repo for the latest code:
import UIKit
import AVKit
import AVFoundation
public class ModalMoviePlayerViewController: UIViewController {
    private let fileName: String
    private let loop: Bool
    private var item: AVPlayerItem!
    private var player: AVPlayer!
    internal private(set) var playerVC: AVPlayerViewController!
    private var waitingToAutostart = true
    public init(fileName: String, loop: Bool = true) {
        self.fileName = fileName
        self.loop = loop
        super.init(nibName: nil, bundle: nil)
    }
    public required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    public override func viewDidLoad() {
        super.viewDidLoad()
        let url = NSBundle.mainBundle().URLForResource(fileName, withExtension: nil)!
        item = AVPlayerItem(URL: url)
        player = AVPlayer(playerItem: item)
        player.actionAtItemEnd = .None
        player.addObserver(self, forKeyPath: "status", options: [], context: nil)
        NSNotificationCenter.defaultCenter().addObserver(self, selector: #selector(ModalMoviePlayerViewController.didPlayToEndTime), name: AVPlayerItemDidPlayToEndTimeNotification, object: item)
        playerVC = AVPlayerViewController()
        playerVC.player = player
        playerVC.videoGravity = AVLayerVideoGravityResizeAspectFill
        playerVC.showsPlaybackControls = false
        let playerView = playerVC.view
        addChildViewController(playerVC)
        view.addSubview(playerView)
        playerView.autoresizingMask = [.FlexibleWidth, .FlexibleHeight]
        playerView.frame = view.bounds
        playerVC.didMoveToParentViewController(self)
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(ModalMoviePlayerViewController.handleTap))
        tapGesture.delegate = self
        view.addGestureRecognizer(tapGesture)
    }
    deinit {
        player.pause()
        player.removeObserver(self, forKeyPath: "status")
        NSNotificationCenter.defaultCenter().removeObserver(self)
    }
    func togglePlayPause() {
        if isPlaying {
            pause()
        } else {
            play()
        }
    }
    func restart() {
        seekToStart()
        play()
    }
    func play() {
        if player.status == .ReadyToPlay {
            player.play()
        } else {
            waitingToAutostart = true
        }
    }
    func pause() {
        player.pause()
        waitingToAutostart = false
    }
    var isPlaying: Bool {
        return (player.rate > 1 - 1e-6) || waitingToAutostart
    }
    private func performStateTransitions() {
        if waitingToAutostart && player.status == .ReadyToPlay {
            player.play()
        }
    }
    public override func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer) {
        performStateTransitions()
    }
    @objc func didPlayToEndTime() {
        if isPlaying && loop {
            seekToStart()
        }
    }
    private func seekToStart() {
        player.seekToTime(CMTimeMake(0, 10))
    }
    public override func touchesBegan(touches: Set, withEvent event: UIEvent?) {
        if !playerVC.showsPlaybackControls {
            playerVC.showsPlaybackControls = true
        }
        super.touchesBegan(touches, withEvent: event)
    }
}
extension ModalMoviePlayerViewController: UIGestureRecognizerDelegate {
    @IBAction func handleTap(sender: UIGestureRecognizer) {
        if !playerVC.showsPlaybackControls {
            playerVC.showsPlaybackControls = true
        }
    }
    /// Prevents delivery of touch gestures to AVPlayerViewController's gesture recognizer,
    /// which would cause controls to hide immediately after being shown.
    ///
    /// `-[AVPlayerViewController _handleSingleTapGesture] goes like this:
    ///
    ///     if self._showsPlaybackControlsView() {
    ///         _hidePlaybackControlsViewIfPossibleUntilFurtherUserInteraction()
    ///     } else {
    ///         _showPlaybackControlsViewIfNeededAndHideIfPossibleAfterDelayIfPlaying()
    ///     }
    public func gestureRecognizer(gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailByGestureRecognizer otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        if !playerVC.showsPlaybackControls {
            // print("\nshouldBeRequiredToFailByGestureRecognizer? \(otherGestureRecognizer)")
            if let tapGesture = otherGestureRecognizer as? UITapGestureRecognizer {
                if tapGesture.numberOfTouchesRequired == 1 {
                    return true
                }
            }
        }
        return false
    }
}