Crafting the Ultimate iOS Video Player: Part 1 — Mastering Custom Control Setup

Ajay Kumar
8 min readNov 5, 2023
Playback Controls

Get ready to embark on a journey through our series, Crafting the Ultimate iOS Video Player. This adventure is all about elevating your iOS video playback experience. We’re about to explore Swift, AVPlayer, and the art of creating custom controls. In this first part, we’ll dive into the world of observers and foundational setup, setting the stage for an extraordinary video player. Additionally, to help you grasp the concepts more effectively, I’ve prepared a sample design for the custom controls.

Player Controls:

The PlayerControlsView is a UIView which serves as the graphical interface for controlling video playback and interaction. It’s designed to overlay on top of the AVPlayer, enabling users to engage with various controls.

The controls available in our setup include back, subtitle, and settings buttons on the top, and previous/next video, rewind/forward, play/pause, forward buttons in the middle. Additionally, we have labels for the video’s title, subtitles, and a seek bar to navigate the video timeline. The video player which we are about to create would be conforming to the PlayerControlsViewDelegate protocol, ensuring that the video player responds to user interactions with these controls.

@objc protocol PlayerControlsViewDelegate {
// Define delegate methods for various player control actions
func seekForward()
func seekBackward()
func togglePlayPause()
....
}

// MARK: - Player Controls

class PlayerControlsView: UIView {
weak var delegate: PlayerControlsViewDelegate?

// MARK: - Controls on Top

private let backButton = UIButton().configure {
$0.setImage(VideoPlayerImage.backButton.uiImage, for: .normal)
}

....

// MARK: - Controls on Middle

private let previousVideoButton = UIButton().configure {
$0.setImage(VideoPlayerImage.previousVideoButton.uiImage, for: .normal)
}

....

// MARK: - Controls on Bottom

private let titleLabel = UILabel().configure {
$0.font = FontUtility.helveticaNeueRegular(ofSize: 20)
$0.textColor = VideoPlayerColor(palette: .white).uiColor
}

....

init() {
super.init(frame: .zero)
setupViews()
setUpEvents()
}

...
}

// MARK: - Player Control Views

extension PlayerControlsView {
private func setupViews() {
// Add the created controls to the view
....
}
}

// MARK: - Player Control Events

extension PlayerControlsView {
private func setUpEvents() {
// Handle control actions like tap, slide, etc.
....
}

@IBAction private func pausePlay(_: UIButton) {
delegate?.togglePlayPause()
}

....
}

Setting Up the Video Player

In this section, we’ll walk through the process of setting up the AVPlayer and integrating it into your video player view controller.

  • Step 1: Initializing the AVPlayer: This is the core component responsible for handling video playback. We create an instance of AVPlayer and set it up for use.
  • Step 2: Adding the Player Layer: To display the video content on the screen, we need to add the AVPlayer’s layer to the view’s layer. This integration ensures that the video is visible and properly rendered within your video player view.
  • Step 3: Setting Up Controls: The setupControls() configures the UI controls for the video player. It sets up elements like total duration, title, subtitles, and control buttons. The playerControlsView is added as a subview to the main view and is designed to overlay the video content.
public class VideoPlayerViewController: UIViewController {
....
private var periodicTimeObserver: Any?
private var didSetupControls: Bool = false
....
var player: AVPlayer?
private var playerLayer: AVPlayerLayer?
var playerItem: AVPlayerItem?
let playerControlsView = PlayerControlsView()

// MARK: - Lifecycle

....

public override func viewDidLoad() {
super.viewDidLoad()
resetOrientation(UIInterfaceOrientationMask.landscapeRight)
UIViewController.attemptRotationToDeviceOrientation()
notification.addObserver(self, selector: #selector(appMovedToBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
setupPlayer()
}

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

// MARK: - Video Player Setup

extension VideoPlayerViewController {
private func setupPlayer() {
guard let videoURL = viewModel.url else { return }
playerItem = AVPlayerItem(url: videoURL)
....
addObservers()
....
playerLayer = AVPlayerLayer(player: player)
guard let playerLayer = playerLayer else { return }
view.backgroundColor = .black
view.layer.addSublayer(playerLayer)
}

private func setupControls() {
guard let totalDuration = player?.currentItem?.duration else { return }

....

view.addSubview(playerControlsView)
playerControlsView.snp.makeConstraints { make in
make.edges.equalToSuperview()
}
playerControlsView.delegate = self

....
}
}

Observers:

Observation is a key part of video player management. The addObservers() function adds observers to monitor changes in the video player’s status and duration. It also sets up a periodic time observer to update the seek bar and current time label during playback. In our custom video player, observers are implemented through the use of Key-Value Observing (KVO) and periodic time observers.

// MARK: - Observers

extension VideoPlayerViewController {
private func addObservers() {
playerItem?.addObserver(self, forKeyPath: "status", options: [.new, .initial], context: nil)
player?.currentItem?.addObserver(self, forKeyPath: "duration", options: [.new, .initial], context: nil)
let interval = CMTime(seconds: 1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
let mainQueue = DispatchQueue.main
periodicTimeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: mainQueue) { [weak self] time in
guard let self = self else { return }
if self.player?.currentItem?.status == .readyToPlay {
self.playerControlsView.seekBarValue = Float(time.seconds)
self.playerControlsView.currentTimeLabelText = time.durationText + "/"
}
}
}

public override func observeValue(forKeyPath keyPath: String?, of _: Any?, change _: [NSKeyValueChangeKey: Any]?, context _: UnsafeMutableRawPointer?) {
switch keyPath {
case "duration":
if let duration = player?.currentItem?.duration, duration.seconds > 0.0, !didSetupControls {
handleDuration(duration.seconds)
resumePlayer()
}
default:
break
}
}

private func handleDuration(_ duration: Double) {
playerControlsView.seekBarMaximumValue = Float(duration)
setupControls()
didSetupControls = true
showControls()
}
}
  • KVO for Player Status: The first observer monitors changes in the status key path of the video player’s current item. This key path is used to track the status of the player, such as whether it’s loading, ready to play, or in an error state. When a change is detected in the status, the observer responds accordingly, ensuring the video player’s behavior aligns with the current state.
  • KVO for Video Duration: The second observer tracks changes in the duration key path of the video player’s current item. This key path is crucial for monitoring the duration of the video. When a valid duration is available and the controls have not been set up, the observer initiates the setup of video controls, such as the seek bar and playback buttons. This ensures that controls are configured and displayed based on the video’s duration.
  • Periodic Time Observer: The periodic time observer is a fundamental component for tracking real-time progress within the video. It is responsible for regularly updating the video playback time. At predefined intervals (in this case, every second), the observer reports the current time within the video. This time information is used to update the seek bar’s position and the displayed current time, offering users a visual representation of their progress in the video.

The combination of these observers ensures that the video player remains synchronized with the video content, providing users with real-time information and control over their playback experience. It also facilitates the setup and display of video controls to enable users to interact with the video in a user-friendly manner. Observers are an integral part of the video player’s functionality, offering real-time insights into the video’s status and progress.

Handling Control Actions through Delegation:

Through delegate methods, the video player provides users with a range of customizable controls to enhance their viewing experience. These controls include navigation options, subtitle management, settings configuration, playlist navigation, seeking within the video, and play/pause functionality. The delegate methods enable users to interact with the video player, adjusting these controls to suit their preferences and ensuring a smooth and personalized playback experience.

// MARK: - Player Control Actions

extension VideoPlayerViewController: PlayerControlsViewDelegate {
func goBack() {
....
}

func seekForward() {
....
}

....

func sliderValueChanged(slider: UISlider, event: UIEvent) {
var pauseTime: CMTime = CMTime.zero
guard let player = player else { return }
if let touchEvent = event.allTouches?.first {
switch touchEvent.phase {
case .began:
player.pause()
guard let currentTime = player.currentItem?.currentTime() else { return }
pauseTime = currentTime
invalidateControlsHiddenTimer()
case .moved:
break
case .ended:
resetControlsHiddenTimer()
let seekingCM = CMTimeMake(value: Int64(slider.value * Float(pauseTime.timescale)), timescale: pauseTime.timescale)
player.seek(to: seekingCM)
/// Retain video player state on seeking: whenever the user interacts with the seek bar, we pause the player internally to calculate the new time. So, once the seek bar action is completed, we would need to retain the original playback state of the player.
viewModel.playerState == .play ? player.play() : player.pause()
default:
break
}
}
}

....
}

ViewModel:

The VideoPlayerViewModel acts as an intermediary between the user interface and the video player. It manages the video player’s state, configuration, and user interface updates, allowing users to interact with the video. It also includes time-related functions for seeking within the video and formatting the video’s duration for display.

enum PlayerState {
case play
case pause
}

public class VideoPlayerViewModel {
private let seekDuration: Float64 = 15
var playerState: PlayerState = .pause
var config: VideoPlayerConfig
weak var delegate: VideoPlayerDelegate?

var url: URL? {
guard let videos = config.playlist.videos, videos.count > 0, let url = videos[config.playlist.currentVideoIndex ?? 0].url else { return nil }
return URL(string: url)
}

....

// MARK: - AVPlayer Time

func getForwardTime(currentTime: CMTime, duration: CMTime) -> CMTime? {
let playerCurrentTime = CMTimeGetSeconds(currentTime)
let newTime = playerCurrentTime + seekDuration

if newTime < CMTimeGetSeconds(duration) {
return CMTimeMake(value: Int64(newTime * 1000 as Float64), timescale: 1000)
}
return CMTimeMake(value: Int64(CMTimeGetSeconds(duration) * 1000 as Float64), timescale: 1000)
}

func getBackwardTime(currentTime: CMTime) -> CMTime? {
let playerCurrentTime = CMTimeGetSeconds(currentTime)
var newTime = playerCurrentTime - seekDuration

if newTime < 0 {
newTime = 0
}
return CMTimeMake(value: Int64(newTime * 1000 as Float64), timescale: 1000)
}

func getFormattedTime(totalDuration: Double) -> String {
let hours = Int(totalDuration.truncatingRemainder(dividingBy: 86400) / 3600)
let minutes = Int(totalDuration.truncatingRemainder(dividingBy: 3600) / 60)
let seconds = Int(totalDuration.truncatingRemainder(dividingBy: 60))

if hours > 0 {
return String(format: "%i:%02i:%02i", hours, minutes, seconds)
}
return String(format: "%02i:%02i", minutes, seconds)
}

.....
}

Playlist Navigation:

When the user navigates through a playlist of videos using the next or previous buttons, the video player doesn’t create new controller instances or player objects. Instead, it efficiently manages the transition by resetting specific aspects:

// MARK: - Reset Player

extension VideoPlayerViewController {
func resetPlayer(with currentVideoIndex: Int) {
resetPlayerItems()
viewModel.config.playlist.currentVideoIndex = currentVideoIndex
resetControlsHiddenTimer()
playerControlsView.seekBarValue = 0
playerControlsView.currentTimeLabelText = "00:00"
setupPlayer()
resumePlayer()
}

private func resetPlayerItems() {
hideControls()
didSetupControls = false
disableGestureRecognizers()
player?.replaceCurrentItem(with: nil)
playerItem = nil
playerLayer = nil
viewModel.playerState = .pause
}

....
}
  • Current Video Index Update
  • Resetting Controls: Resets the control elements, such as the seek bar and time labels, to their initial state, preparing them for the upcoming video.
  • Reinitializing Player: The player is reinitialized by calling the setupPlayer() . This action involves setting up the player item with the new video’s URL and configuring the player for playback.
  • Resuming Playback: After resetting, the player is resumed, maintaining the playback state (play or pause) as it was before the transition.

By following this approach, the video player effectively manages playlist navigation, providing a seamless transition between videos without the need to create entirely new controller instances or player objects.

In this first article of our Crafting the Ultimate iOS Video Player series, we’ve delved into the foundational aspects of building a custom video player.

We have more exciting topics to explore in the upcoming articles, from handling subtitles and video quality settings to advanced features like Watch Party functionality. If you’re eager to dive deeper into iOS video player development, stay tuned for the next articles in this series.

Check out Part 2 of Crafting the Ultimate iOS Video Player — Demystifying Subtitle Handling

And don’t forget to check out the complete source code on my GitHub repository — https://github.com/ajkmr7/Custom-Video-Player for hands-on exploration. Happy coding 🚀

--

--