Episode #419

Tracking Playback Progress

Series: Making a Podcast App From Scratch

33 minutes
Published on November 22, 2019

This video is only available to subscribers. Get access to this video and 573 others.

To wrap up this series, we add a new model to track and persist the progress of playing episodes. We also restore the player and playback position when coming back from a cold launch.

The last major feature we need to the app is tracking user progress. We want to add some data model to store which episodes the user has played, what the current episode is, and how far along into the track the user has played.

So we'll start by defining a new Core Data entity.

Our EpisodeStatus entity will have a class name of EpisodeStatusEntity and have these attributes:

  • lastListenTime (Double)
  • lastPlayedAt (Date)
  • hasCompleted (Bool)
  • isCurrentlyPlaying (Bool)

We'll also add a 1-1 relationship to the Episode entity.

Next we'll add a way to easily fetch which episode is currently playing. In SubscriptionStore.swift:

func findCurrentlyPlayingEpisode() throws -> EpisodeStatusEntity? {
    let fetch: NSFetchRequest<EpisodeStatusEntity> = EpisodeStatusEntity.fetchRequest()
    fetch.fetchLimit = 1
    fetch.predicate = NSPredicate(format: "isCurrentlyPlaying == YES")
    return try context.fetch(fetch).first
}

We'll also need to add a method to find or create the EpisodeStatus for an Episode:

func getStatus(for episode: Episode) throws -> EpisodeStatusEntity? {
    guard let identifier = episode.identifier else { return nil }
    let fetch: NSFetchRequest<EpisodeEntity> = EpisodeEntity.fetchRequest()
    fetch.fetchLimit = 1
    fetch.predicate = NSPredicate(format: "identifier == %@", identifier)

    guard let episode = try context.fetch(fetch).first else {
        return nil
    }

    if let status = episode.status {
        return status
    }

    let status = EpisodeStatusEntity(context: context)
    status.isCurrentlyPlaying = false
    status.lastListenTime = 0
    status.hasCompleted = false
    status.lastPlayedAt = Date()

    status.episode = episode
    return status
}

We are setting all of the properties here since they are all marked as required for the entity to be saved.

Saving progress while playing

In our PlayerViewController we'll need to obtain this status and update it as the user plays a track.

We'll start with a reference to SubscriptionStore:

private var subscriptionStore: SubscriptionStore!

We'll set this up in viewDidLoad:

    subscriptionStore = SubscriptionStore(context: PersistenceManager.shared.mainContext)

Refactoring a bit

We need to clean up our setEpisode() method since it is already too long. We're going to move most of this logic into separate methods, so the current method will be reduced to this:

    func setEpisode(_ episode: Episode, podcast: Podcast, autoPlay: Bool = true) {
        updateUI(for: episode, podcast: podcast)

        guard let audioURL = episode.enclosureURL else { return }
        beginAudioSession()
        cleanupPlayerState()

        preparePlayer(audioURL: audioURL) {
            if autoPlay {
                self.player?.play()
                self.togglePlayPauseButton(isPlaying: true)
            }
        }
    }

We've added an autoPlay flag which we'll use later.

    private func updateUI(for episode: Episode, podcast: Podcast) {
        titleLabel.text = episode.title
        artworkImageView.kf.setImage(with: podcast.artworkURL, options: [.transition(.fade(0.3))])
        playerBar.imageView.kf.setImage(with: podcast.artworkURL)

        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
            self.playerBar.isHidden = false
        }
    }
    private func beginAudioSession() {
        do {
            try configureAudioSession()
        } catch {
            print("ERROR: \(error)")
            showAudioSessionError()
        }
    }
    private func cleanupPlayerState() {
        if player != nil {
            player?.pause()
            if let previousObservation = timeObservationToken {
                player?.removeTimeObserver(previousObservation)
            }
            player = nil
        }
    }
    private func preparePlayer(audioURL: URL, onReady: @escaping () -> Void) {
        let playerItem = AVPlayerItem(url: audioURL)
        let player = AVPlayer(playerItem: playerItem)
        self.player = player

        let time = episodeStatus?.lastListenTime ?? 0
        transportSlider.value = 0
        transportSlider.isEnabled = false
        transportSlider.alpha = 0.5

        statusObservationToken = playerItem.observe(\.status) { (playerItem, change) in
            print("Status: ")
            switch playerItem.status {
            case .failed:
                print("Failed.")
                print("Error: ", playerItem.error?.localizedDescription ?? "<?>")

            case .readyToPlay:
                print("Ready to play")

                player.seek(to: CMTime(seconds: time, preferredTimescale: 1))
                self.transportSlider.value = Float(time / max(playerItem.duration.seconds, 1))
                self.transportSlider.alpha = 1
                self.transportSlider.isEnabled = true

                onReady()

            case .unknown:
                print("Unknown")
            @unknown default:
                break
            }
        }

        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 = "--"
            }
        }
    }

In the preparPlayer method we've changed our observation to track playerItem status instead of player status. This will let us know when the duration of the track is ready so we can update our progress slider.

Note: for real production apps you'll also want to track player status so that you can respond to errors. The documentation states that you should throw away and replace with a new player when the player status becomes failed.

Tracking Progress

In the setEpisode method we'll fetch the current status (or create a new one) and store it in a local variable:

    private var episodeStatus: EpisodeStatusEntity?
    func setEpisode(_ episode: Episode, podcast: Podcast, autoPlay: Bool = true) {
        getEpisodeStatus(for: episode)
        // ...
    }
    private func getEpisodeStatus(for episode: Episode) {
        do {

            if let previousStatus = try subscriptionStore.findCurrentlyPlayingEpisode() {
                previousStatus.isCurrentlyPlaying = false
            }

            episodeStatus = try subscriptionStore.getStatus(for: episode)
            episodeStatus?.isCurrentlyPlaying = true
            episodeStatus?.lastPlayedAt = Date()

            try PersistenceManager.shared.mainContext.save()

        } catch {
            print("Error: ", error)
        }

        if episodeStatus == nil {
            print("WARNING: Episode status was not returned. No progress will be saved.")
        }
    }

If we had a previously current episode, we mark its flag to false then set our new one to true and save it.

Now we have a property we can work with, let's update our time observer to keep track of the last listen time:

        timeObservationToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
            self?.episodeStatus?.lastListenTime = time.seconds
            // ...

We avoid saving the context here because this will be called back very frequently. But this begs the question, where should we save it?

One great place is when the app is going to the background or about to be terminated. In the AppDelegate we can add this method:

    private func trySave() {
        do {
            print("Saving changes...")
            try PersistenceManager.shared.mainContext.save()
        } catch {
            print("Error saving changes: ", error)
        }
    }

Then we can call it when the app is going to the background or about to be killed:

    func applicationDidEnterBackground(_ application: UIApplication) {
        trySave()
    }

    func applicationWillTerminate(_ application: UIApplication) {
        trySave()
    }

Showing the current episode on launch

The last change we're going to make it to show the currently playing episode on cold launch if we have one.

In application(_:didFinishLaunchingWithOptions:):

    let store = SubscriptionStore(context: PersistenceManager.shared.mainContext)
    do {
        if let currentStatus = try store.findCurrentlyPlayingEpisode() {
            guard let episodeEntity = currentStatus.episode else { return }
            let podcastEntity = episodeEntity.podcast

            let episode = Episode(from: episodeEntity)
            let podcast = Podcast(from: podcastEntity)

            // ...
        }

We have to create an episode and podcast from our core data entities, so we do that with an initializer on each.

In Episode.swift:

    init() {        
    }

    init(from entity: EpisodeEntity) {
        identifier = entity.identifier
        title = entity.title
        description = entity.episodeDescription
        publicationDate = entity.publicationDate
        duration = entity.duration
        enclosureURL = entity.enclosureURL
    }

Here we need an empty default initializer since we were using the implicit one before.

In Podcast.swift:

    init(from entity: PodcastEntity) {
        id = entity.id!
        feedURL = URL(string: entity.feedURLString!)!
        title = entity.title
        author = entity.author
        description = entity.podcastDescription
        primaryGenre = entity.genre
        artworkURL = entity.artworkURL
    }

Now that we have these, we have everything we need to present our player:

    let playerVC = PlayerViewController.shared
    playerVC.setEpisode(episode, podcast: podcast, autoPlay: false)

    self.window?.rootViewController?.present(playerVC, animated: true, completion: nil)

This episode uses Xcode 11.2.1, Swift 5.1.