Designing a Custom Video Player in iOS with AVPlayer

Creating a custom video player in iOS allows you to deliver a unique media experience that aligns perfectly with your brand and user expectations. Apple’s AVPlayer, part of the AVFoundation framework, provides a robust foundation for building flexible, performant, and feature-rich video playback interfaces. By pairing AVPlayer with custom UI controls, you gain full control over the look, behavior, and accessibility of your video player. This article walks through the core concepts, practical implementation, and advanced enhancements for building a production-ready custom video player using AVPlayer.

Understanding AVPlayer and AVFoundation

AVPlayer is a versatile object that controls playback of audiovisual media. It can play local files, streaming assets (HLS), and remote URLs with minimal boilerplate. Unlike MPMoviePlayerController (now deprecated) or AVPlayerViewController, AVPlayer gives you a blank canvas — you supply the AVPlayerLayer to render video in a UIView or CALayer. This approach is essential when you want custom overlays, animations, or brand-specific controls.

The framework also provides companion classes like AVPlayerItem, AVAsset, and AVQueuePlayer for advanced use cases like playlists or looping. Observing key-value paths (KVO) on AVPlayer and AVPlayerItem lets you react to playback state changes, buffering progress, and error conditions.

Key Classes and Their Roles

  • AVPlayer: Manages playback, time control, and rate. A single player instance can reuse player items.
  • AVPlayerItem: Represents a single media asset and its playback state. Contains tracks, duration, and current time.
  • AVPlayerLayer: A CALayer subclass that renders video output. Must be added to a view’s layer hierarchy.
  • AVAsset: The underlying media resource. Use it to inspect metadata, timelines, and available media characteristics.

Setting Up the Basic Video Player

Begin by importing AVFoundation and creating a view controller that hosts an AVPlayerLayer. The player layer must be sized to match the view’s bounds and inserted at the back of the layer stack to allow custom controls to appear on top.

import AVFoundation
import UIKit

class VideoPlayerViewController: UIViewController {
    var player: AVPlayer!
    var playerLayer: AVPlayerLayer!

    override func viewDidLoad() {
        super.viewDidLoad()

        guard let url = URL(string: "https://example.com/video.mp4") else { return }
        player = AVPlayer(url: url)

        playerLayer = AVPlayerLayer(player: player)
        playerLayer.frame = view.bounds
        playerLayer.videoGravity = .resizeAspect
        view.layer.insertSublayer(playerLayer, at: 0)

        // Controls will be added later
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        playerLayer.frame = view.bounds
    }
}

Important: Update the player layer’s frame in viewDidLayoutSubviews to handle layout changes, rotation, or resizing. The videoGravity property controls how the video fills the layer. Common values are .resizeAspect (letterbox) and .resizeAspectFill (crop).

Designing Custom Controls

Custom controls are the interface through which users interact with the player. At minimum, you need a play/pause toggle. Most production players also include a progress slider, current time and duration labels, a volume slider (or mute button), and possibly an AirPlay button.

Play/Pause Button

A simple button that toggles between play and pause states. Use player.play() and player.pause(). Update the button’s title or icon based on the current timeControlStatus.

let playPauseButton = UIButton(type: .system)
playPauseButton.translatesAutoresizingMaskIntoConstraints = false
playPauseButton.setTitle("Play", for: .normal)
playPauseButton.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside)

@objc func togglePlayPause() {
    if player.timeControlStatus == .playing {
        player.pause()
        playPauseButton.setTitle("Play", for: .normal)
    } else {
        player.play()
        playPauseButton.setTitle("Pause", for: .normal)
    }
}

For a more robust solution, observe timeControlStatus using KVO and update the button reactively, so it remains in sync even when playback changes programmatically (e.g., after a seek or error).

Progress Slider

A UISlider lets users see and adjust the current playback position. To update the slider in real time, use AVPlayer’s addPeriodicTimeObserver(forInterval:queue:using:). This method delivers time updates at a specified interval (e.g., every half second) and runs the closure on a specified queue (usually the main queue).

let slider = UISlider()
slider.translatesAutoresizingMaskIntoConstraints = false

var timeObserverToken: Any?

override func viewDidLoad() {
    super.viewDidLoad()
    // ... setup player and controls ...
    startObservingTime()
}

func startObservingTime() {
    let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
    timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
        guard let self = self, let duration = self.player.currentItem?.duration.seconds,
              duration > 0 else { return }
        let currentTime = time.seconds
        self.slider.value = Float(currentTime / duration)
    }
}

When the user drags the slider, seek the player to the corresponding time. Be sure to remove the time observer after seeking to prevent the slider from jumping back, then re-add it when the seek completes or after a short delay.

@objc func sliderValueChanged(_ sender: UISlider) {
    guard let duration = player.currentItem?.duration.seconds, duration > 0 else { return }
    let newTime = CMTime(seconds: Double(sender.value) * duration, preferredTimescale: 600)
    player.seek(to: newTime)
}

To improve responsiveness, consider using seek(to:toleranceBefore:toleranceAfter:) with non-zero tolerances for faster seeking.

Time Labels and Buffering Indicator

Display current time and remaining time using UILabel. Format the CMTime using a helper method that converts seconds to mm:ss or hh:mm:ss. For buffering, observe playbackBufferEmpty and playbackLikelyToKeepUp on the AVPlayerItem. Show a spinner or loading indicator while buffering.

Handling Playback States and Buffering

A robust custom player must gracefully handle all playback states: unknown, waiting to play at specified rate, playing, paused. The timeControlStatus property defines these states. Observing status on the player item (AVPlayerItem.Status) tells you whether the asset is ready to play, failed, or unknown.

State Machine Overview

  • Unknown: Asset not yet loaded. Show a loading spinner.
  • Ready to Play: Player item is ready. Enable controls and show the first frame.
  • Failed: An error occurred. Display an error message with a retry option.
player.currentItem?.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)

override func observeValue(forKeyPath keyPath: String?, of object: Any?,
                           change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
    if keyPath == "status", let item = object as? AVPlayerItem {
        switch item.status {
        case .readyToPlay:
            // Enable UI controls
        case .failed:
            // Show error with item.error?.localizedDescription
        case .unknown:
            // Keep showing loading
        @unknown default:
            break
        }
    }
}

Watch out for KVO observer cleanup in deinit. Use removeObserver for each key path.

Adding Advanced Features

Once the basic player works, you can extend it with features that elevate the user experience.

Playback Speed Control

Set the player.rate property to a custom value (e.g., 1.5, 2.0). Note that a rate of 0.0 is equivalent to pause. Provide a speed selection UI, such as a segmented control or a list of options. Keep in mind that audio pitch adjustment is automatic by default, but you can control it via player.currentItem?.audioTimePitchAlgorithm.

@objc func speedChanged(_ sender: UISegmentedControl) {
    let speeds = [0.5, 1.0, 1.5, 2.0]
    player.rate = Float(speeds[sender.selectedSegmentIndex])
    // Restore playback state if paused
}

Subtitles and Closed Captions

AVPlayer supports multiple media selection options via AVPlayerItem.mediaSelectionGroup(forMediaCharacteristic: .audible) and .legible. You can list available subtitle tracks and allow the user to choose. To enable subtitles, select the appropriate option from the group.

if let group = player.currentItem?.mediaSelectionGroup(forMediaCharacteristic: .legible) {
    // Show group.options in UI
    // Set player.currentItem?.select(option, in: group)
}

For best results, ensure your video asset has embedded or external subtitle tracks (e.g., VTT, SRT).

Picture in Picture (PiP)

PiP allows users to watch video in a floating window while using other apps. It requires AVPictureInPictureController initialised with the player layer. Add the background audio capability to your app’s Info.plist to keep audio playing during PiP. Also, conform to AVPictureInPictureControllerDelegate to track state changes.

var pipController: AVPictureInPictureController?

override func viewDidLoad() {
    super.viewDidLoad()
    if AVPictureInPictureController.isPictureInPictureSupported() {
        pipController = AVPictureInPictureController(playerLayer: playerLayer)
        pipController?.delegate = self
    }
}

@objc func togglePiP() {
    if pipController?.isPictureInPictureActive == true {
        pipController?.stopPictureInPicture()
    } else {
        pipController?.startPictureInPicture()
    }
}

Full-Screen Mode and Orientation Handling

To support immersive playback, allow the player to go full-screen and lock or follow device orientation. One common pattern is to present a separate view controller with the player layer resized to fill the screen. Alternatively, you can animate your existing view to full-screen bounds using auto layout constraints.

Orientation Locking

Override supportedInterfaceOrientations in the player view controller to return .landscape when full-screen is active. You can also use UIDevice.current.setValue(UIInterfaceOrientation.landscapeRight.rawValue, forKey: "orientation") to force orientation.

override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
    return isFullScreen ? .landscape : .portrait
}

Be careful not to disrupt the app’s normal orientation behavior. Consider using a modal presentation for full-screen video to isolate rotation handling.

Error Handling and Resilience

Network failures, unsupported codecs, or corrupted assets can cause playback to fail. Always check the player item’s error property and status. When an error occurs, present a clear message to the user and offer retry options. For network errors, implement a retry mechanism with exponential backoff.

if item.status == .failed, let error = item.error {
    showErrorAlert(message: error.localizedDescription)
    // Optionally reload the URL after a delay
}

Also monitor playbackBufferEmpty to detect buffering stalls and show a loading indicator. HLS streams typically handle adaptive bitrate automatically, but you can monitor preferredForwardBufferDuration to adjust buffering behavior.

Performance and Memory Management

Custom video players must be efficient, especially in resource-constrained environments. Here are key practices:

  • Reuse AVPlayerItem: If you frequently switch videos, reuse the same player by replacing the player item. This avoids recreating the player and its associated resources.
  • Remove observers promptly: Unowned or forgotten observers cause crashes and memory leaks. Always remove KVO observers and time observers in deinit or viewWillDisappear.
  • Manage player layer lifecycle: When the view disappears, pause playback and remove the player layer from its superlayer. Recreate it when needed.
  • Use background tasks: For PiP or background audio, set the app’s audio session category to .playback.

Conclusion

Building a custom video player in iOS with AVPlayer gives you complete creative and functional control over the viewing experience. From the foundational setup with AVPlayerLayer to advanced features like subtitles, speed control, and Picture in Picture, the AVFoundation framework provides the tools needed to create a polished product. By also handling states, buffering, and errors with care, your custom player will not only look great but also perform reliably under real-world conditions. As you continue to develop, refer to Apple’s AVPlayer documentation and explore community resources like Ray Wenderlich tutorials for deeper dives. With these patterns, you can deliver a video player that truly stands apart.