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)