big improvements to player
MODIFY
Podcast/Extensions.swift
Podcast/Extensions.swift
@@ -48,9 +48,9 @@ extension UIFont {
extension CMTime {
/// Returns a string representation of the CMTime in the format `h:mm:ss` if time is greater than or equal to one hour, and `mm:ss` if less than one hour
- var durationText: String {
+ var descriptionText: String {
if self.isIndefinite {
- return "-:--"
+ return "0:00"
}
let totalSeconds = Int(self.seconds)
let hours = totalSeconds / 3600

MODIFY
Podcast/Player.swift
Podcast/Player.swift
import UIKit
import AVFoundation
-enum PlayerStatus {
- case empty
- case loading
- case playing
- case paused
- case finished
-}
-
protocol PlayerDelegate: class {
- func playerDidChangeState()
- func playerDidUpdateTime()
+ func updateUI()
}
class Player: NSObject {
-
static let sharedInstance = Player()
private override init() {
- playerStatus = .empty
- shouldPlayWhenLoaded = true
+ player = AVPlayer()
+ autoplay = true
+ currentItemPrepared = false
super.init()
}
- var playerStatus: PlayerStatus {
- didSet {
- if oldValue != playerStatus {
- delegate?.playerDidChangeState()
- }
- }
+ deinit {
+ removeCurrentItemStatusObserver()
+ removeTimeObservers()
}
weak var delegate: PlayerDelegate?
- private var shouldPlayWhenLoaded: Bool
- private var playerContext: UnsafeMutableRawPointer?
+
+ // Mark: KVO variables
+
+ private var playerItemContext: UnsafeMutableRawPointer?
private var timeObserverToken: Any?
- private var currentAVPlayer: AVPlayer? {
+
+ // Mark: Playback variables/methods
+
+ private var player: AVPlayer
+ private(set) var currentEpisode: Episode? {
didSet {
- oldValue?.pause()
+ autoplay = true
+ currentItemPrepared = false
}
}
- private var currentEpisode: Episode?
- deinit {
- do {
- try currentAVPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status), context: &playerContext)
- } catch {}
- removeTimeObserver()
- removeEndOfItemObserver()
+ private var autoplay: Bool
+ private var currentItemPrepared: Bool
+ var isPlaying: Bool {
+ get {
+ return player.rate != 0.0 || (!currentItemPrepared && autoplay && (player.currentItem != nil))
+ }
}
func playEpisode(episode: Episode) {
- if episode.mp3URL == nil || episode == currentEpisode || playerStatus == .loading {
- // currently only support switching episodes when Player isn't already loading
+ if currentEpisode?.id == episode.id {
+ // TODO: decide how to handle this case. do nothing? restart track at beginning?
+ // also, currently all episode ids showing as the same...
+ }
+
+ guard let url = episode.mp3URL else {
+ print("Episode \(episode.title) mp3URL is nil. Unable to play.")
return
}
+
+ if player.status == AVPlayerStatus.failed {
+ if let error = player.error {
+ print(error)
+ }
+ player = AVPlayer()
+ }
+
+ // cleanup any previous AVPlayerItem
+ pause()
+ removeCurrentItemStatusObserver()
+
currentEpisode = episode
- playerStatus = .loading
- currentAVPlayer = AVPlayer(url: episode.mp3URL!)
- currentAVPlayer?.addObserver(self, forKeyPath: #keyPath(AVPlayer.status), options: [.old, .new], context: &playerContext)
+ let asset = AVAsset(url: url)
+ let playerItem = AVPlayerItem(asset: asset,
+ automaticallyLoadedAssetKeys: ["playable"])
+ playerItem.addObserver(self,
+ forKeyPath: #keyPath(AVPlayerItem.status),
+ options: [.old, .new],
+ context: &playerItemContext)
+ player.replaceCurrentItem(with: playerItem)
}
func play() {
- guard let player = currentAVPlayer else { return }
+ if let currentItem = player.currentItem {
+ if currentItem.status == .readyToPlay {
player.play()
- playerStatus = .playing
- addTimeObserver()
- addEndOfItemObserver()
+ addTimeObservers()
+ } else {
+ autoplay = true
}
-
- func pause() {
- guard let player = currentAVPlayer else { return }
- player.pause()
- playerStatus = .paused
- removeTimeObserver()
- removeEndOfItemObserver()
}
-
- func addTimeObserver() {
- guard let player = currentAVPlayer else { return }
- timeObserverToken = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, Int32(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] _ in
- self?.delegate?.playerDidUpdateTime()
- })
}
- func removeTimeObserver() {
- guard let token = timeObserverToken, let player = currentAVPlayer else { return }
-
- player.removeTimeObserver(token)
- timeObserverToken = nil
+ func pause() {
+ if let currentItem = player.currentItem {
+ if currentItem.status == .readyToPlay {
+ player.pause()
+ removeTimeObservers()
+ } else {
+ autoplay = false
}
-
- func addEndOfItemObserver() {
- NotificationCenter.default.addObserver(self, selector: #selector(currentItemDidFinish), name: .AVPlayerItemDidPlayToEndTime, object: currentAVPlayer?.currentItem)
}
-
- func removeEndOfItemObserver() {
- NotificationCenter.default.removeObserver(self)
}
func togglePlaying() {
- switch playerStatus {
- case .loading:
- shouldPlayWhenLoaded = !shouldPlayWhenLoaded
- case .playing:
+ if isPlaying {
pause()
- case .paused:
- play()
- case .finished:
- setProgress(progress: 0.0)
+ } else {
play()
- default:
- break
}
+ delegate?.updateUI()
}
- func skip(seconds: Float64) {
- guard let player = currentAVPlayer, playerStatus != .empty || playerStatus != .loading else { return }
- let newTime = CMTimeAdd(player.currentTime(), CMTimeMakeWithSeconds(seconds, Int32(NSEC_PER_SEC)))
-
- // if player had finished and skip is backwards in time, move to a paused state
- if playerStatus == .finished && seconds < 0 {
- playerStatus = .paused
+ func skip(seconds: Double) {
+ if let currentTime = player.currentItem?.currentTime() {
+ let newTime = CMTimeAdd(currentTime, CMTime(seconds: seconds, preferredTimescale: CMTimeScale(1.0)))
+ player.currentItem?.seek(to: newTime)
+ delegate?.updateUI()
}
- player.seek(to: newTime)
- delegate?.playerDidUpdateTime()
}
-
- func getProgress() -> Float {
- if playerStatus == .empty || playerStatus == .loading || currentAVPlayer?.currentItem == nil {
- return 0.0
+ func getProgress() -> Double {
+ if let currentItem = player.currentItem {
+ let currentTime = currentItem.currentTime()
+ let durationTime = currentItem.duration
+ if !durationTime.isIndefinite && durationTime.seconds != 0 {
+ return currentTime.seconds / durationTime.seconds
}
- return Float((currentAVPlayer?.currentItem?.currentTime().seconds)!) / Float((currentAVPlayer?.currentItem?.duration.seconds)!)
+ }
+ return 0.0
}
- func setProgress(progress: Float) {
- if playerStatus == .empty || playerStatus == .loading {
- return
+ func setProgress(progress: Double) {
+ if let duration = player.currentItem?.duration {
+ if !duration.isIndefinite {
+ player.currentItem!.seek(to: CMTime(seconds: duration.seconds * min(max(progress, 0.0), 1.0), preferredTimescale: CMTimeScale(1.0)))
+ delegate?.updateUI()
+ }
+ }
}
- var progress = progress
+ // Warning: these next three functions should only be used to set UI element values
- if progress < 0.0 {
- progress = 0.0
- } else if progress > 1.0 {
- progress = 1.0
+ func currentItemDuration() -> CMTime {
+ guard let duration = player.currentItem?.duration else {
+ return CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(1.0))
+ }
+ return duration.isIndefinite ? CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(1.0)) : duration
}
- if let duration = currentAVPlayer?.currentItem?.duration {
- let newTime = progress * Float(duration.seconds)
- currentAVPlayer?.seek(to: CMTimeMakeWithSeconds(Float64(newTime), Int32(NSEC_PER_SEC)))
- if playerStatus == .finished {
- playerStatus = .paused
+ func currentItemElapsedTime() -> CMTime {
+ return player.currentItem?.currentTime() ?? CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(1.0))
}
+
+ func currentItemRemainingTime() -> CMTime {
+ guard let duration = player.currentItem?.duration else {
+ return CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(1.0))
}
+ let elapsedTime = currentItemElapsedTime()
+ return CMTimeSubtract(duration, elapsedTime)
}
- func getDuration() -> CMTime? {
- return currentAVPlayer?.currentItem?.duration
- }
+ // Mark: KVO methods
- func getTimePassed() -> CMTime? {
- if playerStatus == .finished {
- return getDuration()
+ func addTimeObservers() {
+ timeObserverToken = player.addPeriodicTimeObserver(forInterval: CMTimeMakeWithSeconds(1.0, Int32(NSEC_PER_SEC)), queue: DispatchQueue.main, using: { [weak self] _ in
+ self?.delegate?.updateUI()
+ })
+ NotificationCenter.default.addObserver(self, selector: #selector(currentItemDidPlayToEndTime), name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem)
}
- return currentAVPlayer?.currentItem?.currentTime()
+
+ func removeTimeObservers() {
+ guard let token = timeObserverToken else { return }
+ player.removeTimeObserver(token)
+ timeObserverToken = nil
+ NotificationCenter.default.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem)
}
- func getTimeLeft() -> CMTime? {
- guard let timePassed = getTimePassed(), let duration = getDuration() else {
- return nil
+ func removeCurrentItemStatusObserver() {
+ // observeValue(...) will take care of removing AVPlayerItem.status observer once it is
+ // readyToPlay, so we only need to remove observer if AVPlayerItem isn't readyToPlay yet
+ if let currentItem = player.currentItem {
+ if currentItem.status != .readyToPlay {
+ currentItem.removeObserver(self,
+ forKeyPath: #keyPath(AVPlayer.status),
+ context: &playerItemContext)
}
- return CMTimeSubtract(duration, timePassed)
}
-
- func currentItemDidFinish() {
- playerStatus = .finished
- removeEndOfItemObserver()
- delegate?.playerDidChangeState()
- delegate?.playerDidUpdateTime()
}
- func shouldDisplayPlayButton() -> Bool {
- switch playerStatus {
- case .empty, .finished, .paused:
- // equivalend to .paused; should indicate that Player instance has paused playback
- return true
- case .playing:
- return false
- case .loading:
- // equivalent to .playing; in this case, playback is defined
- // by whether Player will autoplay once track is loaded
- return !shouldPlayWhenLoaded
- }
+ func currentItemDidPlayToEndTime() {
+ removeTimeObservers()
+ delegate?.updateUI()
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
- guard context == &playerContext else {
- super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
+ // Only handle observations for the playerItemContext
+ guard context == &playerItemContext else {
+ super.observeValue(forKeyPath: keyPath,
+ of: object,
+ change: change,
+ context: context)
return
}
- if keyPath == #keyPath(AVPlayer.status) {
- let status: AVPlayerStatus
+ if keyPath == #keyPath(AVPlayerItem.status) {
+ let status: AVPlayerItemStatus
if let statusNumber = change?[.newKey] as? NSNumber {
- status = AVPlayerStatus(rawValue: statusNumber.intValue)!
+ status = AVPlayerItemStatus(rawValue: statusNumber.intValue)!
} else {
status = .unknown
}
switch status {
case .readyToPlay:
- playerStatus = .paused
- if shouldPlayWhenLoaded {
- togglePlaying()
- }
+ print("AVPlayerItem ready to play")
+ currentItemPrepared = true
+ if autoplay { play() }
case .failed:
- playerStatus = .empty
- default:
- break
- }
- currentAVPlayer?.removeObserver(self, forKeyPath: #keyPath(AVPlayer.status), context: &playerContext)
+ print("Failed to load AVPlayerItem")
+ case .unknown:
+ print("Unknown AVPlayerItemStatus")
+ }
+ // remove observer after having reading the AVPlayerItem status
+ player.currentItem?.removeObserver(self,
+ forKeyPath: #keyPath(AVPlayerItem.status),
+ context: &playerItemContext)
}
}
}

MODIFY
Podcast/PlayerViewController.swift
Podcast/PlayerViewController.swift
@@ -41,7 +41,7 @@ class PlayerViewController: UIViewController, PlayerDelegate {
var backwardsLabel: UILabel!
var rightTimeLabel: UILabel!
var leftTimeLabel: UILabel!
- var isScrubbing = false
+ var scrubbing = false
override func viewDidLoad() {
super.viewDidLoad()
@@ -60,17 +60,11 @@ class PlayerViewController: UIViewController, PlayerDelegate {
episodeNameLabel = UILabel(frame: .zero)
episodeNameLabel.textAlignment = .center
episodeNameLabel.font = .boldSystemFont(ofSize: 15)
- episodeNameLabel.text = "Episode Name"
- episodeNameLabel.sizeToFit()
- episodeNameLabel.center = CGPoint(x: view.frame.width/2, y: EpisodeNameLabelYValue)
controlsView.addSubview(episodeNameLabel)
seriesNameLabel = UILabel(frame: .zero)
seriesNameLabel.textAlignment = .center
seriesNameLabel.font = .systemFont(ofSize: 13)
- seriesNameLabel.text = "Series Name"
- seriesNameLabel.sizeToFit()
- seriesNameLabel.center = CGPoint(x: view.frame.width/2, y: SeriesNameLabelYValue)
controlsView.addSubview(seriesNameLabel)
slider = UISlider(frame: .zero)
@@ -87,17 +81,11 @@ class PlayerViewController: UIViewController, PlayerDelegate {
leftTimeLabel = UILabel(frame: .zero)
leftTimeLabel.font = .systemFont(ofSize: 12)
leftTimeLabel.textAlignment = .center
- leftTimeLabel.text = "-:--"
- leftTimeLabel.sizeToFit()
- leftTimeLabel.center = CGPoint(x: TimeLabelHorizontalInset + leftTimeLabel.frame.width/2, y: SliderCenterY)
controlsView.addSubview(leftTimeLabel)
rightTimeLabel = UILabel(frame: .zero)
rightTimeLabel.font = .systemFont(ofSize: 12)
rightTimeLabel.textAlignment = .center
- rightTimeLabel.text = "-:--"
- rightTimeLabel.sizeToFit()
- rightTimeLabel.center = CGPoint(x: view.frame.width - rightTimeLabel.frame.width/2 - TimeLabelHorizontalInset, y: SliderCenterY)
controlsView.addSubview(rightTimeLabel)
playPauseButton = UIButton(frame: .zero)
@@ -140,32 +128,20 @@ class PlayerViewController: UIViewController, PlayerDelegate {
}
override func viewDidAppear(_ animated: Bool) {
- playerDidUpdateTime()
- playerDidChangeState()
+ updateUI()
}
func endScrubbing() {
- Player.sharedInstance.setProgress(progress: slider.value)
- isScrubbing = false
+ Player.sharedInstance.setProgress(progress: Double(slider.value))
+ scrubbing = false
+ updateUI()
}
func sliderValueChanged() {
- isScrubbing = true
-
- // update time labels to be the slider value
- guard let duration = Player.sharedInstance.getDuration() else { return }
-
- let newSeconds = duration.seconds * Double(slider.value)
- let newTimePassed = CMTime(seconds: newSeconds, preferredTimescale: Int32(NSEC_PER_SEC))
- let newTimeLeft = CMTimeSubtract(duration, newTimePassed)
-
- leftTimeLabel.text = newTimePassed.durationText
- leftTimeLabel.sizeToFit()
- rightTimeLabel.text = newTimeLeft.durationText
- rightTimeLabel.sizeToFit()
+ scrubbing = true
+ // TODO: update time labels as you scrub
}
-
func playPauseButtonPress() {
Player.sharedInstance.togglePlaying()
}
@@ -178,35 +154,39 @@ class PlayerViewController: UIViewController, PlayerDelegate {
Player.sharedInstance.skip(seconds: -15.0)
}
- // Mark: PlayerDelegate
-
- func playerDidUpdateTime() {
- // don't update slider value and time labels if currently scrubbing
- if isScrubbing { return }
+ // Mark: PlayerDelegate protocol
- slider.value = Player.sharedInstance.getProgress()
-
- if let timePassed = Player.sharedInstance.getTimePassed() {
- leftTimeLabel.text = timePassed.durationText
+ func updateUI() {
+ if Player.sharedInstance.isPlaying {
+ playPauseButton.setBackgroundImage(#imageLiteral(resourceName: "Pause"), for: .normal)
} else {
- leftTimeLabel.text = "-:--"
+ playPauseButton.setBackgroundImage(#imageLiteral(resourceName: "Play"), for: .normal)
}
- if let timeLeft = Player.sharedInstance.getTimeLeft() {
- rightTimeLabel.text = timeLeft.durationText
+ if let episode = Player.sharedInstance.currentEpisode {
+ leftTimeLabel.text = Player.sharedInstance.currentItemElapsedTime().descriptionText
+ rightTimeLabel.text = Player.sharedInstance.currentItemRemainingTime().descriptionText
+ episodeNameLabel.text = episode.title
+ seriesNameLabel.text = episode.series?.title ?? "no series"
} else {
- rightTimeLabel.text = "-:--"
+ leftTimeLabel.text = "0:00"
+ rightTimeLabel.text = "0:00"
+ episodeNameLabel.text = "no episode"
+ seriesNameLabel.text = "no series"
}
leftTimeLabel.sizeToFit()
rightTimeLabel.sizeToFit()
- }
+ episodeNameLabel.sizeToFit()
+ seriesNameLabel.sizeToFit()
- func playerDidChangeState() {
- if Player.sharedInstance.shouldDisplayPlayButton() {
- playPauseButton.setBackgroundImage(#imageLiteral(resourceName: "Play"), for: .normal)
- } else {
- playPauseButton.setBackgroundImage(#imageLiteral(resourceName: "Pause"), for: .normal)
+ leftTimeLabel.center = CGPoint(x: TimeLabelHorizontalInset + leftTimeLabel.frame.width/2, y: SliderCenterY)
+ rightTimeLabel.center = CGPoint(x: view.frame.width - rightTimeLabel.frame.width/2 - TimeLabelHorizontalInset, y: SliderCenterY)
+ episodeNameLabel.center = CGPoint(x: view.frame.width/2, y: EpisodeNameLabelYValue)
+ seriesNameLabel.center = CGPoint(x: view.frame.width/2, y: SeriesNameLabelYValue)
+
+ if !scrubbing {
+ slider.value = Float(Player.sharedInstance.getProgress())
}
}
}

big improvements to player