
This video is only available to subscribers. Start a subscription today to get access to this and 472 other videos.
Audio Playback
This episode is part of a series: Making a Podcast App From Scratch.
Using AVPlayer to Load the Track
We will start by importing AVFoundation
and declaring a property for an AVPlayer
. Currently, an AVPlayer
instance will represent a single track at a time. To load the next item we will discard the old one and create a new AVPlayer
.
To start with we will first configure an Audio Session in PlayerViewController
. Using the shared AVAudioSession
, we will set the category & mode and set the session to be active. As we may encounter an error while configuring our audio session, we will allow the function to throw these errors.
private func configureAudioSession() throws {
let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback)
try session.setMode(.spokenAudio)
try session.setActive(true, options: [])
}
We will now add a function to display an audio session error, occurred while configuring the audio session.
private func showAudioSessionError() {
let alert = UIAlertController(title: "Playback Error", message: "There was an error configuring the audio system for playback.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
present(alert, animated: true, completion: nil)
}
Next, we will try configuring the audio session, while we set the episode.
do {
try configureAudioSession()
} catch {
print("ERROR: \(error)")
showAudioSessionError()
}
Before we create a new player we will check for an existing player and discard it.
if player != nil {
player?.pause()
player = nil
}
Finally, we will create an AVPlayer
using the audio URL of the episode. Once the audio is buffered sufficiently, we will be able to hear the player playing.
guard let audioURL = episode.enclosureURL else { return }
let player = AVPlayer(url: audioURL)
player.play()
Next, we will handle the play/pause button tap. By alternating the state of the button we will let the player to play or pause depending on what action is appropriate.
@IBAction func playPause(_ sender: Any) {
let wasPlaying = playPauseButton.isSelected
playPauseButton.isSelected.toggle()
if wasPlaying {
player?.pause()
} else {
player?.play()
}
Configuring the Transport Slider
When the view loads the slider is disabled, so when we start playing we'll enable the slider and reset its position to 0.
transportSlider.isEnabled = true
transportSlider.value = 0
We need to update the slider every quarter of a second, so we will add periodic time observer.
let interval = CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObservationToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main)
We will maintain a strong reference to the return value of Periodic Time Observer for a given player.
private var timeObservationToken: Any?
Next, we will remove the time observer before we destroy the player.
if player != nil {
player?.pause()
if let previousObservation = timeObservationToken {
player?.removeTimeObserver(previousObservation)
}
player = nil
}
Now we will set the progress value to the slider. We will check for the isTracking
property of slider, before we set the progress of the slider. This will avoid the automatic updates from the player when the slider is tracked by the user.
private func updateSlider(for time: CMTime) {
guard !transportSlider.isTracking else { return }
guard let duration = player?.currentItem?.duration else { return }
let progress = time.seconds / duration.seconds
transportSlider.value = Float(progress)
}
Setting the Labels for Progressed and Remaining Time
We will create an extension to calculate the progressed and remaining time and return the formatted string of these calculated values.
import CoreMedia
extension CMTime {
var formattedString: String {
// 1:12:34
// 23:09
let totalSeconds = seconds
let hours = Int(totalSeconds / 3600)
let minutes = Int(totalSeconds.truncatingRemainder(dividingBy: 3600)) / 60
let seconds = Int(totalSeconds.truncatingRemainder(dividingBy: 60))
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
} else {
return String(format: "%02d:%02d", minutes, seconds)
}
}
}
Next, we will update the slider and labels with progressed and remaining time. Here we use [weak self]
in the block to avoid a retain cycle.
let interval = CMTime(seconds: 0.25, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObservationToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
self?.updateSlider(for: time)
self?.timeProgressedLabel.text = time.formattedString
if let duration = player.currentItem?.duration {
let remaining = duration - time
self?.timeRemainingLabel.text = "-" + remaining.formattedString
} else {
self?.timeRemainingLabel.text = "--"
}
}
Updating the Player While Scrubbing
Now we will seek in the track while the user scrubs the slider.
@IBAction func transportSliderChanged(_ sender: Any) {
guard let player = player else { return }
guard let currentItem = player.currentItem else { return }
let seconds = currentItem.duration.seconds * Double(transportSlider.value)
let time = CMTime(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
player.seek(to: time)
}
For skipping forward and backward, we will define a constant 10 second time interval.
private let skipTime = CMTime(seconds: 10, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
Next, we will update the player and slider when skipping back & forward, taking care not to go less than 0 or more than the total duration of the track.
@IBAction func skipBack(_ sender: Any) {
guard let player = player else { return }
let time = player.currentTime() - skipTime
if time < CMTime.zero {
player.seek(to: .zero)
updateSlider(for: .zero)
} else {
player.seek(to: time)
updateSlider(for: time)
}
}
@IBAction func skipForward(_ sender: Any) {
guard let player = player else { return }
guard let currentItem = player.currentItem else { return }
let time = player.currentTime() + skipTime
if time >= currentItem.duration {
player.seek(to: currentItem.duration)
updateSlider(for: currentItem.duration)
} else {
player.seek(to: time)
updateSlider(for: time)
}
}