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) } }