When playing a video in an AVPlayer, you sometimes want to be aware of the buffering in order to update your interface, for instance you can:
- show an activity indicator when the player stalls due to buffering
- create your own progress bar and show in a different color than the progression the point up to where the video is loaded
(Note that in the following examples, I consider being at the view model level and update dynamic properties that could be observed by a view controller using KVO to react and update the interface, try using reactive programming with RxSwift or Combine instead).
Detecting changes in buffer state
In order to show an activity indicator when the player stalls, we need to register 3 observers using KVO (Key-Value Observing) on the following dynamic properties of an AVPlayerItem:
- isPlaybackBufferEmpty
- isPlaybackBufferFull
- isPlaybackLikelyToKeepUp
@objc private(set) dynamic var isStall: Bool = false
// MARK: - Buffering KVO
private var isPlaybackBufferEmptyObserver: NSKeyValueObservation?
private var isPlaybackBufferFullObserver: NSKeyValueObservation?
private var isPlaybackLikelyToKeepUpObserver: NSKeyValueObservation?
private func observeBuffering(for playerItem: AVPlayerItem) {
isPlaybackBufferEmptyObserver = playerItem.observe(\.isPlaybackBufferEmpty, changeHandler: onIsPlaybackBufferEmptyObserverChanged)
isPlaybackBufferFullObserver = playerItem.observe(\.isPlaybackBufferFull, changeHandler: onIsPlaybackBufferFullObserverChanged)
isPlaybackLikelyToKeepUpObserver = playerItem.observe(\.isPlaybackLikelyToKeepUp, changeHandler: onIsPlaybackLikelyToKeepUpObserverChanged)
}
private func onIsPlaybackBufferEmptyObserverChanged(playerItem: AVPlayerItem, change: NSKeyValueObservedChange<Bool>) {
if playerItem.isPlaybackBufferEmpty {
isStall = true
}
}
private func onIsPlaybackBufferFullObserverChanged(playerItem: AVPlayerItem, change: NSKeyValueObservedChange<Bool>) {
if playerItem.isPlaybackBufferFull {
isStall = false
}
}
private func onIsPlaybackLikelyToKeepUpObserverChanged(playerItem: AVPlayerItem, change: NSKeyValueObservedChange<Bool>) {
if playerItem.isPlaybackLikelyToKeepUp {
isStall = false
}
}
When the updates are receiving, we can then react accordingly:
- isPlaybackBufferEmpty = true: the player has to fill the buffer, definitely stalling, this is a good place to start the activity indicator
- isPlaybackBufferFull = true: the player has filled the buffer completely, at this stage it has more than enough to play, not stalling, the activity indicator must be stopped
- isPlaybackLikelyToKeepUp = true: the player has filled enough of the buffer to start playing, at this stage, it will restart playing if not paused and is not stalling, the activity indicator can be stopped
Detecting up to what point of the video is buffered
In order to know and convert the loading time ranges into a percentage of the video, we will need to retrieve and extract different pieces of information:
- the video duration
- the available times aka what’s been loaded already
Getting video duration
For the duration of the video, again, an observer on the duration
property of the AVPlayerItem
and using KVO will do the trick:
@objc private(set) dynamic var duration: TimeInterval = 0.0
// MARK: - Duration KVO
private var durationObserver: NSKeyValueObservation?
private func observeDuration(for playerItem: AVPlayerItem) {
durationObserver = playerItem.observe(\.duration, changeHandler: { [weak self] (playerItem, _) in
self?.duration = playerItem.duration.seconds
})
}
Receiving periodic time updates
At the AVPlayer
level, we can add a periodic time observer that will call our callback as close as the requested interval as possible, in the following case every half-second:
let player = AVPlayer(playerItem: playerItem)
player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)),
queue: DispatchQueue.main,
using: handleTimeChanged)
So every 1/2 second, we enter our callback, and this is a good place to refresh our local representation of the buffer:
private func handleTimeChanged(time: CMTime) {
refreshBuffered()
refreshProgression(time: time)
}
Refreshing loaded buffer
The AVPlayerItem
has a property called loadedTimeRanges
that has everything we need. We get its timeRangeValue
if it exist, and then compose the available duration.
Based on the video duration, we can transform the available duration (or buffered duration) into a percentage of the video:
@objc private(set) dynamic var buffered: Float = 0.0
private func refreshBuffered() {
buffered = Float(availableDuration / duration)
}
private var availableDuration: Double {
guard let timeRange = player.currentItem?.loadedTimeRanges.first?.timeRangeValue else {
return 0.0
}
let startSeconds = timeRange.start.seconds
let durationSeconds = timeRange.duration.seconds
return startSeconds + durationSeconds
}
This is not perfect, obviously the buffer doesn’t contain the whole video data between 0.0 and availableDuration, but this is good enough to show on a UIProgressView.
Bonus: refreshing progression
Because we receive periodic time updates, it is also a good place to update our progression in the model, here after I do it in two forms:
- currentTime (TimeInterval) to be formatted and displayed in a label
- progress (Float) to configure a UISlider and see progress visually
@objc private(set) dynamic var currentTime: TimeInterval = 0.0
@objc private(set) dynamic var progress: Float = 0.0
private func refreshProgression(time: CMTime) {
currentTime = time.seconds
progress = Float(currentTime / duration)
}