The Kaltura Player does not inherently include a UI, but this guide will walk you through an example of how to add play/pause buttons and a scrubber to your player.
At this point you should have a Kaltura Player embedded in your application and showing your entry, as seen in this guide.

We will start by adding button images of your choice to the Assets catalogue of your project. Then, in the storyboard, create a play/pause button, a slider (scrubber), a current position label, and a (remaining) duration label. For the play/pause button, in the Attributes Inspector, click the dropdown for Title and select Attributed. Under Image, type in the name of the play button file (it should have autocomplete for existing files).
Lastly, create the outlets for all your new objects in the Controller:

@IBOutlet weak var playPauseButton: UIButton!
@IBOutlet weak var playheadSlider: UISlider!
@IBOutlet weak var positionLabel: UILabel!
@IBOutlet weak var durationLabel: UILabel!

Player State

Currently our play button is only set to show a play icon, and we’ll want it to change for different scenarios. What we’ll need is to handle the state of what’s happening in the player - whether it is idle, playing, paused, or ended. Let’s add an enum called state at the top of the class:

enum State {
    case idle, playing, paused, ended
}

And a Property Observer on that enum which switches on each state.

var state: State = .idle {
      didSet {
          switch state {
          case .idle:
              playPauseButton.setImage(UIImage(named: "btn_play"), for: .normal)
          case .playing:
              playPauseButton.setImage(UIImage(named: "btn_pause"), for: .normal)
          case .paused:
              playPauseButton.setImage(UIImage(named: "btn_play"), for: .normal)
          case .ended:
              playPauseButton.setImage(UIImage(named: "btn_refresh"), for: .normal)
          }
        }
    }

What this does is listen for a change to the state, and based on whether the player is currently idle, paused, or playing, the picture of the play/pause button is changed accordingly.

At the beginning of the viewDidLoad function, set the state to idle.

self.state = .idle

On the playPauseButton, add a new IBAction for a “Touch Up Inside” event and link it to a new playerTouched function that calls play/pause on the player when the button is touched. Available Basic Player actions can be found here

@IBAction func playTouched(_ sender: Any) {
    guard let player = self.player else {
        print("player is not set")
        return
    }

    switch state {
    case .playing:
        player.pause()
    case .idle:
        player.play()
    case .paused:
        player.play()
    case .ended:
        player.seek(to: 0)
        player.play()
    }
}

Player Slider

The slider is made up of a few components: the playhead, the current time stamp, and the duration of the entry. All of this configuration happens in the setupPlayer function. Firstly, a formatter for displaying the number of seconds as HH:MM:SS

let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .positional
formatter.zeroFormattingBehavior = .pad

func format(_ time: TimeInterval) -> String {
    if let s = formatter.string(from: time) {
        return s.count > 7 ? s : "0" + s
    } else {
        return "00:00:00"
    }
}

Then, we’ll add three observers. The first one checks on the media’s progress in the task queue every fifth of a second and then updates the player slider accordingly, as well as the text of the current position label (using the time formatter)

self.player?.addPeriodicObserver(interval: 0.2, observeOn: DispatchQueue.main, using: { (pos) in
    self.playheadSlider.value = Float(pos)
    self.positionLabel.text = format(pos)
})

The second observer waits for Player event durationChanged which happens when the media loads and the duration of the video is known. This happens once per playback. It then sets the maximum value of the slider playhead, and the text of the duration label.

self.player?.addObserver(self, events: [PlayerEvent.durationChanged], block: { (event) in
    if let e = event as? PlayerEvent.DurationChanged, let d = e.duration as? TimeInterval {
        self.playheadSlider.maximumValue = Float(d)
        self.durationLabel.text = format(d)
    }
})

The third observer listens for player events, and updates the State when the player begins playing, is paused, or has ended, which is what the switch case above is dependent on. Player events can be found here

self.player?.addObserver(self, events: [PlayerEvent.play, PlayerEvent.ended, PlayerEvent.pause], block: { (event) in
    switch event {
    case is PlayerEvent.Play, is PlayerEvent.Playing:
        self.state = .playing

    case is PlayerEvent.Pause:
        self.state = .paused

    case is PlayerEvent.Ended:
        self.state = .ended

    default:
        break
    }
})

Lastly, in the Storyboard, set a new referencing action on the Playhead Slider for the “Value Changed” event. Call it playheadValueChanged. Add it after the playTouched function. It should set the current time position according to the value of the palyhead, and change the State to paused in the case of moving the slider back after the video has ended.

@IBAction func playheadValueChanged(_ sender: Any) {
    guard let player = self.player else {
        print("player is not set")
        return
    }

    if state == .ended && playheadSlider.value < playheadSlider.maximumValue {
        state = .paused
    }
    player.currentTime = TimeInterval(playheadSlider.value)
}

You should be able to run the code, and use the basic player functions. The complete code should look like this:

import UIKit
import PlayKit
import PlayKitUtils
import PlayKitProviders

fileprivate let SERVER_BASE_URL = "https://cdnapisec.kaltura.com"
fileprivate let PARTNER_ID = 1424501
fileprivate let ENTRY_ID = "1_djnefl4e"

class ViewController: UIViewController {

    enum State {
        case idle, playing, paused, ended
    }
    
    var entryId: String?
    var ks: String?
    var player: Player? // Created in viewDidLoad
    var state: State = .idle {
          didSet {
              switch state {
              case .idle:
                  playPauseButton.setImage(UIImage(named: "btn_play"), for: .normal)
              case .playing:
                  playPauseButton.setImage(UIImage(named: "btn_pause"), for: .normal)
              case .paused:
                  playPauseButton.setImage(UIImage(named: "btn_play"), for: .normal)
              case .ended:
                  playPauseButton.setImage(UIImage(named: "btn_refresh"), for: .normal)
              }
          }
      }
    
    @IBOutlet weak var playerContainer: PlayerView!
    @IBOutlet weak var playPauseButton: UIButton!
    @IBOutlet weak var playheadSlider: UISlider!
    @IBOutlet weak var positionLabel: UILabel!
    @IBOutlet weak var durationLabel: UILabel!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.state = .idle

        self.player = try! PlayKitManager.shared.loadPlayer(pluginConfig: nil)
        self.setupPlayer()
        
        entryId = ENTRY_ID
        self.loadMedia()
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
    }
    
    override var preferredStatusBarStyle : UIStatusBarStyle {
        return UIStatusBarStyle.lightContent
    }
    
/************************/
// MARK: - Player Setup
/***********************/
    func setupPlayer() {
        
        self.player?.view = self.playerContainer
        
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.hour, .minute, .second]
        formatter.unitsStyle = .positional
        formatter.zeroFormattingBehavior = .pad
        
        func format(_ time: TimeInterval) -> String {
            if let s = formatter.string(from: time) {
                return s.count > 7 ? s : "0" + s
            } else {
                return "00:00:00"
            }
        }

        // Observe media progress
        self.player?.addPeriodicObserver(interval: 0.2, observeOn: DispatchQueue.main, using: { (pos) in
            self.playheadSlider.value = Float(pos)
            self.positionLabel.text = format(pos)
        })
        
        // Observe duration
        self.player?.addObserver(self, events: [PlayerEvent.durationChanged], block: { (event) in
            if let e = event as? PlayerEvent.DurationChanged, let d = e.duration as? TimeInterval {
                self.playheadSlider.maximumValue = Float(d)
                self.durationLabel.text = format(d)
            }
        })

        // Observe play/pause
        self.player?.addObserver(self, events: [PlayerEvent.play, PlayerEvent.ended, PlayerEvent.pause], block: { (event) in
            switch event {
            case is PlayerEvent.Play, is PlayerEvent.Playing:
                self.state = .playing
                
            case is PlayerEvent.Pause:
                self.state = .paused
                
            case is PlayerEvent.Ended:
                self.state = .ended
                
            default:
                break
            }
        })
    }

    func loadMedia() {
        let sessionProvider = SimpleSessionProvider(serverURL: SERVER_BASE_URL, partnerId: Int64(PARTNER_ID), ks: ks)
        let mediaProvider: OVPMediaProvider = OVPMediaProvider(sessionProvider)
        mediaProvider.entryId = entryId
        mediaProvider.loadMedia { (mediaEntry, error) in
            if let me = mediaEntry, error == nil {
                let mediaConfig = MediaConfig(mediaEntry: me, startTime: 0.0)
                if let player = self.player {
                    player.prepare(mediaConfig)
                }
            }
        }
    }
    
/************************/
// MARK: - Actions
/***********************/
    
    @IBAction func playTouched(_ sender: Any) {
        guard let player = self.player else {
            print("player is not set")
            return
        }
        
        switch state {
        case .playing:
            player.pause()
        case .idle:
            player.play()
        case .paused:
            player.play()
        case .ended:
            player.seek(to: 0)
            player.play()
        }
    }
    
    @IBAction func playheadValueChanged(_ sender: Any) {
        guard let player = self.player else {
            print("player is not set")
            return
        }
        
        if state == .ended && playheadSlider.value < playheadSlider.maximumValue {
            state = .paused
        }
        player.currentTime = TimeInterval(playheadSlider.value)
    }
}