Audio Playback

Episode #405 | 24 minutes | published on August 15, 2019 | Uses Xcode-10.2.1, Swift-5.0
Subscribers Only
In this episode we implement one of the core functions of a podcast player: playing audio! Using AVPlayer we load up the track and observe its progress so we can update the UI to reflect time progressed, time remaining, as well as allowing the user to scrub to a position in the track.

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)
    }
}
blog comments powered by Disqus