Skip to content

Instantly share code, notes, and snippets.

@FlorianHardyDev
Last active June 6, 2022 02:45
Show Gist options
  • Select an option

  • Save FlorianHardyDev/3cf5863aa83e26d493edfe6fcb48ab24 to your computer and use it in GitHub Desktop.

Select an option

Save FlorianHardyDev/3cf5863aa83e26d493edfe6fcb48ab24 to your computer and use it in GitHub Desktop.
Podcast Player
import AVKit
import MediaPlayer
import SDWebImage
import UIKit
// This class handle audio for podcast and videos inside the all application
class PlayerManager: NSObject {
// Audio components
var videoPlayer = AVPlayer()
var podcastPlayer = AVPlayer()
var modifiedRate: Float = 1
private var playbackLikelyToKeepUpContext = 0
private var podcastNowPlayingInfo = [String: Any]()
struct Observation {
weak var observer: PodcastPlayerObserver?
}
private var observations = [ObjectIdentifier: Observation]()
// View components
var bigPlayerWindow: UIWindow?
var playerDetailViewController: PlayerDetailsViewController?
var floatingButtonController: FloatingButtonController?
var smallPlayerLastLocation: CGPoint?
private var isAVAudioSessionActive = false
// Data informations
private var playlist: Playlist?
private var currentIndex: Int = 0
private var state = State.paused(nil) {
// We add a property observer on 'state', which lets us
// run a function on each value change.
didSet { stateDidChange() }
}
// MARK: - Lifecycle
deinit {
self.videoPlayer.removeObserver(self, forKeyPath: "rate")
self.podcastPlayer.removeObserver(self, forKeyPath: "currentItem.playbackLikelyToKeepUp")
NotificationCenter.default.removeObserver(self)
}
// MARK: - Singleton
static var shared: PlayerManager = {
let playerManager = PlayerManager()
do {
// This is necessary if you want to have audio in the app !
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback)
} catch {
Logger.log(.error, "Unable to set AVAudioSession Category: \(error)")
}
playerManager.podcastPlayer = AVPlayer()
playerManager.podcastPlayer.automaticallyWaitsToMinimizeStalling = false
playerManager.videoPlayer = AVPlayer()
playerManager.setupVideoPlayer()
return playerManager
}()
}
// MARK: - Configuration
extension PlayerManager {
// MARK: Configuration (Audio)
private func setupVideoPlayer() {
self.videoPlayer.addObserver(self, forKeyPath: "rate", options: NSKeyValueObservingOptions.new, context: nil)
}
private func setupAVAudioSession() {
if !self.isAVAudioSessionActive {
do {
try AVAudioSession.sharedInstance().setActive(true)
NotificationCenter.default.addObserver(self,
selector: #selector(handleInterruption(notification:)),
name: AVAudioSession.interruptionNotification,
object: nil)
NotificationCenter.default.addObserver(self,
selector: #selector(handleSecondaryAudio),
name: AVAudioSession.silenceSecondaryAudioHintNotification,
object: AVAudioSession.sharedInstance())
Logger.log(.info, "AVAudioSession is Active and Category Playback is set")
UIApplication.shared.beginReceivingRemoteControlEvents()
self.setupCommandCenter()
self.isAVAudioSessionActive = true
} catch {
Logger.log(.error, "Unable to activate audio session: \(error.localizedDescription)")
}
}
}
private func removeAVAudioSession() {
if self.isAVAudioSessionActive {
do {
try AVAudioSession.sharedInstance().setActive(false)
Logger.log(.info, "AVAudioSession is deactivate")
UIApplication.shared.endReceivingRemoteControlEvents()
self.removeCommandCenter()
self.isAVAudioSessionActive = false
} catch {
Logger.log(.error, "Unable to deactivate audio session: \(error)")
}
}
}
// MARK: Configuration (View)
private func setupSmallPlayer(with style: UIStatusBarStyle) {
if floatingButtonController == nil {
floatingButtonController = FloatingButtonController()
floatingButtonController?.floatingButtonDelegate = self
floatingButtonController?.statusBarStyle = style
floatingButtonController?.showWindow()
}
}
private func setupBigPlayer() {
guard let playlistSafe = self.playlist else {
return
}
self.playerDetailViewController = PlayerDetailsViewController(with: playlistSafe.audios[currentIndex],
from: playlistSafe)
if bigPlayerWindow == nil {
let statusBarHeight = UIApplication.shared.statusBarFrame.height
bigPlayerWindow = UIWindow(frame: CGRect(x: 0.0,
y: statusBarHeight,
width: UIScreen.main.bounds.width,
height: UIScreen.main.bounds.height - statusBarHeight))
bigPlayerWindow?.addShadow(shadowColor: .black,
offSet: CGSize(width: 2.6, height: 2.6),
opacity: 0.8,
shadowRadius: 5.0,
cornerRadius: PlayerDetailsViewController.Constants.cornerRadius,
corners: [UIRectCorner.topLeft, UIRectCorner.topRight])
bigPlayerWindow?.rootViewController = self.playerDetailViewController
bigPlayerWindow?.makeKeyAndVisible()
bigPlayerWindow?.windowLevel = .normal
bigPlayerWindow?.layer.zPosition = .infinity
bigPlayerWindow?.isHidden = true
setupBigPlayerSwipeGestures()
} else {
bigPlayerWindow?.rootViewController = self.playerDetailViewController
}
}
private func setupBigPlayerSwipeGestures() {
let swipeDown = UISwipeGestureRecognizer(target: self,
action: #selector(handleGestureOnBigPlayer))
swipeDown.direction = .down
bigPlayerWindow?.addGestureRecognizer(swipeDown)
}
}
// MARK: - Podcast loading
extension PlayerManager {
func launchPodcastPlaylist(with style: UIStatusBarStyle,
playlist: Playlist) {
self.podcastPlayer.automaticallyWaitsToMinimizeStalling = false
self.playlist = playlist
self.currentIndex = playlist.currentIndex
self.setupSmallPlayer(with: style)
self.setupAVAudioSession()
guard let playlistSafe = self.playlist,
let audioSafe = playlistSafe.audioToPlay else {
return
}
self.loadPodcast(audio: audioSafe)
}
private func loadPodcast(audio: Audio, andPlay shouldPlay: Bool = true) {
guard let stringUrl = audio.url, let safeUrl = URL(string: stringUrl) else {
return
}
self.podcastPlayer = AVPlayer(playerItem: AVPlayerItem(url: safeUrl))
// Register for notification
NotificationCenter.default.addObserver(self,
selector: #selector(playerItemDidReadyToPlay(notification:)),
name: .AVPlayerItemNewAccessLogEntry,
object: self.podcastPlayer.currentItem)
self.podcastPlayer.addObserver(self,
forKeyPath: "currentItem.playbackLikelyToKeepUp",
options: .new,
context: &playbackLikelyToKeepUpContext)
NotificationCenter.default.addObserver(self,
selector: #selector(playerItemDidReachEnd),
name: .AVPlayerItemDidPlayToEndTime,
object: self.podcastPlayer.currentItem)
self.updateInfoCenter(with: audio)
self.updateCommandCenter()
if shouldPlay {
self.playPodcast(freshlyLoaded: true)
}
// Setup the big player after the AVPlayer because it is required inside
self.setupBigPlayer()
}
}
// MARK: - Podcast controls
extension PlayerManager {
func playPodcast(freshlyLoaded: Bool = false) {
videoPlayer.pause()
if !isAVAudioSessionActive {
self.setupAVAudioSession()
}
if self.podcastPlayer.currentItem == nil {
if let playlistSafe = self.playlist {
self.loadPodcast(audio: playlistSafe.audios[currentIndex])
}
}
if freshlyLoaded {
self.podcastPlayer.playImmediately(atRate: 1.0)
self.modifiedRate = 1.0
} else {
self.podcastPlayer.playImmediately(atRate: modifiedRate)
}
state = .playing(podcastPlayer.currentItem)
self.playerDetailViewController?.setPlayButton()
}
func pausePodcast() {
self.podcastPlayer.pause()
state = .paused(podcastPlayer.currentItem)
}
private func stopPodcast() {
self.podcastPlayer.seek(to: CMTime.zero)
self.pausePodcast()
}
}
// MARK: - Video loading
extension PlayerManager {
func loadVideo(url: URL) {
self.pausePodcastAndRemoveAudioSession()
self.videoPlayer.replaceCurrentItem(with: AVPlayerItem(url: url))
}
}
// MARK: - Observers adding/removing
extension PlayerManager {
func addObserver(_ observer: PodcastPlayerObserver) {
let identifier = ObjectIdentifier(observer)
observations[identifier] = Observation(observer: observer)
}
func removeObserver(_ observer: PodcastPlayerObserver) {
let identifier = ObjectIdentifier(observer)
observations.removeValue(forKey: identifier)
}
}
// MARK: - Observers
extension PlayerManager {
override func observeValue(forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey: Any]?,
context: UnsafeMutableRawPointer?) {
if context == &playbackLikelyToKeepUpContext {
guard let safeItem = self.podcastPlayer.currentItem else {
return
}
self.state = .loading(safeItem, !safeItem.isPlaybackLikelyToKeepUp)
if safeItem.isPlaybackLikelyToKeepUp && isPodcastPlaying() {
self.floatingButtonController?.playAnimation()
} else {
self.floatingButtonController?.stopAnimation()
}
}
// Observe the video player
if keyPath == "rate" && videoPlayer.error == nil {
// When the video player is played
if self.videoPlayer.rate != 0.0 {
if self.isAVAudioSessionActive {
// The audio needs to be paused or stopped to remove audio session
self.videoPlayer.pause()
self.pausePodcastAndRemoveAudioSession()
// After removing audio session, the audio can be played
self.videoPlayer.play()
}
} else if self.isPodcastPlaying() {
self.setupAVAudioSession()
}
}
}
@objc
func playerItemDidReachEnd(notification: NSNotification) {
if isNextPodcastAvailable() {
self.podcastNextPressed()
} else {
self.pausePodcast()
if let playlistSafe = self.playlist,
self.currentIndex == playlistSafe.audios.count - 1,
let audioSafe = playlistSafe.audios.first {
self.currentIndex = 0
self.loadPodcast(audio: audioSafe, andPlay: false)
self.podcastPlayer.seek(to: CMTime.zero)
} else {
self.podcastPlayer.seek(to: CMTime.zero)
}
}
}
@objc
func playerItemDidReadyToPlay(notification: Notification) {
if let item = notification.object as? AVPlayerItem {
if item.status == .readyToPlay {
self.updateNowPlayingInfo()
self.updateCommandCenter()
self.playerDetailViewController?.setSlider(with: item)
} else if item.status == .failed {
Logger.log(.error, "something went wrong. player.error should contain some information")
}
}
}
@objc
func handleSecondaryAudio(notification: Notification) {
// Determine hint type
guard let userInfo = notification.userInfo,
let typeValue = userInfo[AVAudioSessionSilenceSecondaryAudioHintTypeKey] as? UInt,
let type = AVAudioSession.SilenceSecondaryAudioHintType(rawValue: typeValue) else {
return
}
if type == .begin {
// Other app audio started playing - mute secondary audio
self.pausePodcast()
} else {
// Other app audio stopped playing - restart secondary audio
self.playPodcast()
}
}
@objc
func handleInterruption(notification: Notification) {
guard let userInfo = notification.userInfo,
let typeInt = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
let type = AVAudioSession.InterruptionType(rawValue: typeInt) else {
return
}
switch type {
case .began:
self.pausePodcast()
case .ended:
if let optionInt = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
let options = AVAudioSession.InterruptionOptions(rawValue: optionInt)
if options.contains(.shouldResume) {
self.playPodcast()
}
}
// do nothing (here if other case are added in future versions)
@unknown default: break
}
}
}
// MARK: - Getters
extension PlayerManager {
func getCurrentPlaylist() -> Playlist? {
return self.playlist
}
func getCurrentPodcastUrl() -> String? {
return (self.podcastPlayer.currentItem?.asset as? AVURLAsset)?.url.absoluteString
}
func isPodcastPlaying() -> Bool {
return self.podcastPlayer.rate != 0.0
}
func isNextPodcastAvailable() -> Bool {
guard let playlistSafe = self.playlist else {
return false
}
return self.currentIndex < playlistSafe.audios.count - 1
}
func isVideoPlayerInFullscreenMode() -> Bool {
guard let appDelegate = UIApplication.shared.delegate as? AppDelegate,
let controller = appDelegate.topViewController(with: appDelegate.window?.rootViewController),
let aClass = NSClassFromString("AVFullScreenViewController"),
controller.isKind(of: aClass) else {
return false
}
return true
}
}
// MARK: - Setters
extension PlayerManager {
func setPreferedStatusBarStyle(with barStyle: UIStatusBarStyle) {
guard let fab = floatingButtonController else {
return
}
fab.statusBarStyle = barStyle
fab.setNeedsStatusBarAppearanceUpdate()
}
// This method is called only when app is coming from background !
func updateMiniPlayerAnimation() {
let keepUp = self.podcastPlayer.currentItem?.isPlaybackLikelyToKeepUp ?? false
self.isPodcastPlaying() && keepUp
? self.floatingButtonController?.playAnimation()
: self.floatingButtonController?.stopAnimation()
}
}
// MARK: - Actions
extension PlayerManager {
@objc
private func handleGestureOnBigPlayer(gesture: UISwipeGestureRecognizer) {
if gesture.direction == .down {
hidePlayerDetails()
}
}
@objc
func hidePlayerDetails() {
bigPlayerWindow?.isHidden = true
floatingButtonController?.showWindow()
}
private func showPlayerDetails() {
self.floatingButtonController?.removeWindow()
self.bigPlayerWindow?.isHidden = false
}
func enterFullScreen() {
self.pausePodcast()
self.floatingButtonController?.removeWindow()
self.bigPlayerWindow?.isHidden = true
}
func exitFullScreen() {
self.floatingButtonController?.showWindow()
}
func podcastGoTo(time: Float) {
self.podcastPlayer.seek(to: CMTime(seconds: Double(time),
preferredTimescale: self.podcastPlayer.currentTime().timescale)) {_ in
self.updateNowPlayingInfo()
}
}
private func podcastSeekTo(time: CMTime) {
self.podcastPlayer.seek(to: time) {_ in
self.updateNowPlayingInfo()
}
}
func podcastBackwardPressed() {
let newTime = CMTimeGetSeconds(self.podcastPlayer.currentTime()) - 10
self.podcastSeekTo(time: CMTimeMake(value: Int64(newTime * 1000 as Float64), timescale: 1000))
}
func podcastPreviousPressed() {
let newCurrentIndex = currentIndex - 1
if newCurrentIndex < 0 {
self.podcastSeekTo(time: CMTime(seconds: 0,
preferredTimescale: self.podcastPlayer.currentTime().timescale))
} else {
guard let playlistSafe = self.playlist else {
return
}
self.currentIndex = newCurrentIndex
self.loadPodcast(audio: playlistSafe.audios[newCurrentIndex])
}
}
func podcastPlayPressed() {
isPodcastPlaying() ? pausePodcast() : playPodcast()
}
func podcastNextPressed() {
let newCurrentIndex = currentIndex + 1
guard let playlistSafe = self.playlist,
newCurrentIndex <= playlistSafe.audios.count - 1 else {
return
}
self.currentIndex = newCurrentIndex
self.loadPodcast(audio: playlistSafe.audios[newCurrentIndex])
}
func podcastForwardPressed() {
let newTime = CMTimeGetSeconds(self.podcastPlayer.currentTime()) + 10
self.podcastSeekTo(time: CMTimeMake(value: Int64(newTime * 1000 as Float64), timescale: 1000))
}
@discardableResult
func changeRate() -> Float? {
// Calculate the new rate
self.modifiedRate += 0.25
if self.modifiedRate > 2 {
self.modifiedRate = 1
}
// If the podcast player was playing
if self.podcastPlayer.rate != 0 {
// Change the rate directly
self.podcastPlayer.rate = self.modifiedRate
}
// If it's not playing, the rate will be changed at the next playPoscast()
return self.modifiedRate
}
private func pausePodcastAndRemoveAudioSession() {
self.pausePodcast()
if self.isAVAudioSessionActive {
self.clearNowPlayingInfo()
self.removeAVAudioSession()
}
}
}
// MARK: - Notification
extension PlayerManager {
private func getImage(from url: URL, completion: @escaping (UIImage?) -> Void) {
URLSession.shared.dataTask(with: url, completionHandler: {data, _, _ in
if let data = data {
var image = UIImage(data: data)
if let imageSafe = image {
image = imageSafe.cropToBounds(image: imageSafe)
}
completion(image)
}
}).resume()
}
private func setupNowPlayingInfo(with artwork: MPMediaItemArtwork, for audio: Audio) {
guard let safeCurrentItem = self.podcastPlayer.currentItem else {
return
}
self.podcastNowPlayingInfo = [
MPMediaItemPropertyTitle: audio.title,
MPMediaItemPropertyArtist: "",
MPMediaItemPropertyAlbumTitle: "",
MPMediaItemPropertyArtwork: artwork,
MPNowPlayingInfoPropertyPlaybackRate: self.podcastPlayer.rate,
MPMediaItemPropertyPlaybackDuration: CMTimeGetSeconds(safeCurrentItem.duration),
MPNowPlayingInfoPropertyElapsedPlaybackTime: CMTimeGetSeconds(safeCurrentItem.currentTime())
]
// Set the metadata
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.podcastNowPlayingInfo
}
private func updateNowPlayingInfo() {
guard let safeCurrentItem = self.podcastPlayer.currentItem else {
return
}
self.podcastNowPlayingInfo.updateValue(self.podcastPlayer.rate,
forKey: MPNowPlayingInfoPropertyPlaybackRate)
self.podcastNowPlayingInfo.updateValue(CMTimeGetSeconds(safeCurrentItem.duration),
forKey: MPMediaItemPropertyPlaybackDuration)
self.podcastNowPlayingInfo.updateValue(CMTimeGetSeconds(self.podcastPlayer.currentTime()),
forKey: MPNowPlayingInfoPropertyElapsedPlaybackTime)
MPNowPlayingInfoCenter.default().nowPlayingInfo = self.podcastNowPlayingInfo
}
private func clearNowPlayingInfo() {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
}
private func updateInfoCenter(with audio: Audio) {
guard let url = URL(string: audio.picture.url) else { return }
getImage(from: url) { [weak self] image in
guard let self = self,
let downloadedImage = image else {
return
}
let artwork = MPMediaItemArtwork(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in
return downloadedImage
})
self.setupNowPlayingInfo(with: artwork, for: audio)
}
}
}
// MARK: - Command center
extension PlayerManager {
private func updateCommandCenter() {
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = isNextPodcastAvailable()
}
private func setupCommandCenter() {
MPRemoteCommandCenter.shared().playCommand.isEnabled = true
MPRemoteCommandCenter.shared().pauseCommand.isEnabled = true
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = true
MPRemoteCommandCenter.shared().playCommand.addTarget { [weak self] _ -> MPRemoteCommandHandlerStatus in
if self?.podcastPlayer.rate == 0.0 {
self?.playPodcast()
return .success
}
return .commandFailed
}
MPRemoteCommandCenter.shared().pauseCommand.addTarget { [weak self] _ -> MPRemoteCommandHandlerStatus in
if self?.podcastPlayer.rate != 0.0 {
self?.pausePodcast()
return .success
}
return .commandFailed
}
MPRemoteCommandCenter.shared().previousTrackCommand.addTarget { [weak self] _ -> MPRemoteCommandHandlerStatus in
self?.podcastPreviousPressed()
return .success
}
MPRemoteCommandCenter.shared().nextTrackCommand.addTarget { [weak self] _ -> MPRemoteCommandHandlerStatus in
self?.podcastNextPressed()
return .success
}
}
private func removeCommandCenter() {
MPRemoteCommandCenter.shared().playCommand.isEnabled = false
MPRemoteCommandCenter.shared().pauseCommand.isEnabled = false
MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false
MPRemoteCommandCenter.shared().playCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(nil)
MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nil)
}
}
// MARK: - FloatingButtonDelegate extension
extension PlayerManager: FloatingButtonDelegate {
func closePodcastPlayer() {
if self.podcastPlayer.currentItem != nil {
TagManager.shared.tagEvent(.eventTagPlayerQuitAction, attributes: [
.tagAttributeFileName: self.getCurrentPodcastUrl() ?? ""
])
}
self.stopPodcast()
self.podcastPlayer.replaceCurrentItem(with: nil)
self.floatingButtonController?.removeWindow()
self.floatingButtonController = nil
self.bigPlayerWindow = nil
self.playerDetailViewController = nil
self.removeAVAudioSession()
}
func onSmallPlayerTapped() {
self.showPlayerDetails()
}
}
// MARK: - AVPlayerViewControllerDelegate extension
extension PlayerManager: AVPlayerViewControllerDelegate {
// swiftlint:disable line_length
func playerViewController(_ playerViewController: AVPlayerViewController,
willBeginFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
self.enterFullScreen()
}
func playerViewController(_ playerViewController: AVPlayerViewController,
willEndFullScreenPresentationWithAnimationCoordinator coordinator: UIViewControllerTransitionCoordinator) {
// Full screen video can be dismiss with a swipe gesture,
// so you need to check if the user ended the gesture
// to know if it is really dismissed
coordinator.animate(alongsideTransition: nil) { context in
if !context.isCancelled {
self.exitFullScreen()
}
}
}
// swiftlint:enable line_length
}
private extension PlayerManager {
func stateDidChange() {
for (identifier, observation) in observations {
// If the observer is no longer in memory, we
// can clean up the observation for its ID
guard let observer = observation.observer else {
observations.removeValue(forKey: identifier)
continue
}
switch state {
case .playing(let item):
observer.podcastPlayer(self.podcastPlayer, didStartPlaying: item)
case .paused(let item):
observer.podcastPlayer(self.podcastPlayer, didPausePlaybackOf: item)
case let .loading(item, isLoading):
observer.podcastPlayer(self.podcastPlayer, isLoading: isLoading, item: item)
}
}
}
}
// MARK: - State enum
private extension PlayerManager {
enum State {
case playing(AVPlayerItem?)
case paused(AVPlayerItem?)
case loading(AVPlayerItem?, Bool)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment