Last active
June 6, 2022 02:45
-
-
Save FlorianHardyDev/3cf5863aa83e26d493edfe6fcb48ab24 to your computer and use it in GitHub Desktop.
Revisions
-
FlorianHardyDev revised this gist
Mar 19, 2021 . 2 changed files with 417 additions and 0 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,411 @@ import AVKit import Lottie import UIKit class FloatingButtonController: UIViewController { // MARK: - Variables private let window = FloatingButtonWindow() private(set) var button: UIView! private(set) var deleteView: UIView! private(set) var insideView: UIView! private(set) var assetView: AssetView! private(set) var iconeView: UIImageView! weak var floatingButtonDelegate: FloatingButtonDelegate? var rotatingProgressBar = RotatingCircularGradientProgressBar() var deletingPoint = CGPoint.zero var statusBarStyle: UIStatusBarStyle = .default override var preferredStatusBarStyle: UIStatusBarStyle { return statusBarStyle } // MARK: - init required init?(coder: NSCoder) { super.init(coder: coder) } init() { super.init(nibName: nil, bundle: nil) window.windowLevel = UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude) window.isHidden = false window.rootViewController = self NotificationCenter.default.addObserver(self, selector: #selector(keyboardDidShow(note:)), name: UIResponder.keyboardDidShowNotification, object: nil) PlayerManager.shared.addObserver(self) } deinit { PlayerManager.shared.removeObserver(self) NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardDidShowNotification, object: nil) } // MARK: - LifeCycle override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() setNeedsStatusBarAppearanceUpdate() insideView?.layer.masksToBounds = false insideView?.layer.applySketchShadow(color: UIColor.darkGray, alpha: 0.4, xVal: 0, yVal: 0, blur: 6, spread: 0) insideView?.layer.shouldRasterize = true insideView?.layer.rasterizationScale = UIScreen.main.scale } override func loadView() { // The init of the viewcontroller view let customView = UIView() // The init of the fab let xPos = UIScreen.main.bounds.size.width - Constants.offset - Constants.buttonWidth / 2 let yPos = UIScreen.main.bounds.size.height - HomeTabBarViewController.Constants.tabBarHeight - Constants.buttonHeight - Constants.offset var initialPoint = CGPoint(x: xPos, y: yPos) if let lastPosition = PlayerManager.shared.smallPlayerLastLocation { initialPoint = lastPosition } else { PlayerManager.shared.smallPlayerLastLocation = initialPoint } // The init of the inside view self.setupInsideView() // The init of the delete button self.setupDeleteButton() // The init of the animation (lottie) self.setupAssetView() // The paused state self.setupPausedState() // The loading state self.setupRotatingProgressBar() // Adding all the views in the insideView self.insideView.addSubviewToCenterContainer(self.iconeView) self.insideView.addSubviewToCenterContainer(self.rotatingProgressBar) self.insideView.addSubviewToCenterContainer(self.assetView) // The buttonView let buttonView = UIView(frame: CGRect(x: initialPoint.x - Constants.buttonWidth / 2, y: initialPoint.y - Constants.buttonHeight / 2, width: Constants.buttonWidth, height: Constants.buttonHeight)) buttonView.backgroundColor = UIColor.clear buttonView.layer.cornerRadius = 0.5 * buttonView.bounds.size.width buttonView.clipsToBounds = true // Adding the insideView in the button buttonView.addSubviewToCenterContainer(self.insideView) // Adding delete and button in the view customView.addSubview(self.deleteView) self.hideDeleteView() customView.addSubview(buttonView) self.view = customView self.button = buttonView // Adding the tap let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap)) self.button.addGestureRecognizer(tapGesture) // Set the window fab window.button = button // Adding the gesture let panner = UIPanGestureRecognizer(target: self, action: #selector(panDidFire(panner:))) self.button.addGestureRecognizer(panner) } } // MARK: - Setup extension FloatingButtonController { private func setupInsideView() { self.insideView = UIView(frame: CGRect(x: Constants.buttonWidth / 2 - Constants.insideWidth / 2, y: Constants.buttonHeight / 2 - Constants.insideHeight / 2, width: Constants.insideWidth, height: Constants.insideHeight)) self.insideView.backgroundColor = R.color.accent_color() self.insideView.layer.cornerRadius = 0.5 * self.insideView.bounds.size.width self.insideView.clipsToBounds = true } private func setupDeleteButton() { let rect = UIScreen.main.bounds.insetBy(dx: 0, dy: Constants.offset + Constants.buttonHeight / 2) self.deletingPoint = CGPoint(x: rect.midX, y: rect.maxY) self.deleteView = UIView(frame: CGRect(x: deletingPoint.x - Constants.deleteWidth / 2, y: deletingPoint.y - Constants.deleteHeight / 2, width: Constants.deleteWidth, height: Constants.deleteHeight)) self.deleteView.borderWidth = 2 self.deleteView.borderColor = R.color.grey_dusty() let deleteImage = UIImageView(image: R.image.ico_cross()) deleteImage.translatesAutoresizingMaskIntoConstraints = false deleteImage.tintColor = UIColor.white self.deleteView.addSubviewToCenterContainer(deleteImage) self.deleteView.backgroundColor = UIColor.black.withAlphaComponent(0.3) self.deleteView.layer.cornerRadius = 0.5 * self.deleteView.bounds.size.width self.deleteView.borderColor = UIColor.white self.deleteView.borderWidth = 2 self.deleteView.clipsToBounds = true } private func setupAssetView() { self.assetView = AssetView() self.assetView.frame = insideView.bounds.inset(by: UIEdgeInsets(horizontal: 25, vertical: 25)) self.assetView.alpha = 0 guard let safeAssetView = assetView, let url = R.file.soundbarJson() else { return } safeAssetView.configure(with: .lottie(resource: url, loopMode: .loop, closure: nil, completion: nil), contentMode: .scaleAspectFill) } private func setupPausedState() { self.iconeView = UIImageView() self.iconeView.frame = self.assetView.frame self.iconeView.image = R.image.ico_soundbar_off() self.iconeView.tintColor = UIColor.white } private func setupRotatingProgressBar() { self.rotatingProgressBar = RotatingCircularGradientProgressBar() self.rotatingProgressBar.frame = insideView.bounds.inset(by: UIEdgeInsets(horizontal: 10, vertical: 10)) self.rotatingProgressBar.color = UIColor.white self.rotatingProgressBar.gradientColor = UIColor.white self.rotatingProgressBar.backgroundColor = UIColor.clear self.rotatingProgressBar.progress = 0.85 } } // MARK: - Private Methods extension FloatingButtonController { private func hideDeleteView() { self.deleteView?.alpha = 0 } private func showDeleteView() { self.deleteView?.alpha = 1 } private func updateLoadingState(isLoading: Bool) { if isLoading { // force the animation to be recreated self.rotatingProgressBar.isHidden = false self.rotatingProgressBar.animate() } else { self.rotatingProgressBar.removeAnimation() self.rotatingProgressBar.isHidden = true } } private func snapButtonToSocket(deleteZone: CGRect) { if deleteZone.contains(button.center) { button.center = deletingPoint deletePlayer() self.view.isHidden = true } else { let bestSocket = getBestSocket(deleteZoneCenter: deletingPoint) if bestSocket == deletingPoint { button.center = deletingPoint deletePlayer() } else { button.center = bestSocket PlayerManager.shared.smallPlayerLastLocation = bestSocket } } } } // MARK: - Calcul Methods extension FloatingButtonController { private func getPossiblePoints() -> [CGPoint] { var possiblePoints: [CGPoint] = [] // the current y position var yPos: CGFloat = button.center.y let minY = Constants.offset + Constants.buttonHeight / 2 let maxY = UIScreen.main.bounds.size.height - Constants.offset - Constants.buttonHeight / 2 // Check if fab is out of limits if button.center.y < minY { // limit top yPos = minY } else if button.center.y > maxY { // limit bottom yPos = maxY } // limit right possiblePoints.append(CGPoint(x: UIScreen.main.bounds.size.width - Constants.offset - Constants.buttonWidth / 2, y: yPos)) // limit left possiblePoints.append(CGPoint(x: Constants.offset + Constants.buttonWidth / 2, y: yPos)) // return the possible points return possiblePoints } private func getBestSocket(deleteZoneCenter: CGPoint) -> CGPoint { var bestSocket = CGPoint.zero var distanceToBestSocket = CGFloat.infinity var possiblePoints: [CGPoint] = getPossiblePoints() possiblePoints.append(deleteZoneCenter) for socket in possiblePoints { let distance = hypot(button.center.x - socket.x, button.center.y - socket.y) if distance < distanceToBestSocket { distanceToBestSocket = distance bestSocket = socket } } return bestSocket } private func deletePlayer() { removeWindow() floatingButtonDelegate?.closePodcastPlayer() } } // MARK: - Actions extension FloatingButtonController { func playAnimation() { guard let safeAssetView = assetView else { return } safeAssetView.play() UIView.animate(withDuration: Constants.animationDuration) { safeAssetView.alpha = 1 } } func stopAnimation() { guard let safeAssetView = assetView else { return } safeAssetView.stop() UIView.animate(withDuration: Constants.animationDuration) { safeAssetView.alpha = 0 } } @objc func handleTap() { self.floatingButtonDelegate?.onSmallPlayerTapped() } @objc func keyboardDidShow(note: NSNotification) { window.windowLevel = UIWindow.Level(rawValue: 0) window.windowLevel = UIWindow.Level(rawValue: .greatestFiniteMagnitude) } @objc func panDidFire(panner: UIPanGestureRecognizer) { let translation = panner.translation(in: view) panner.setTranslation(CGPoint.zero, in: view) if panner.state == .began { // Animate the appearing of the delete button UIView.animate(withDuration: Constants.animationDuration) { self.showDeleteView() } } if panner.state == .changed { let newButtonView = UIView(frame: CGRect(x: self.button.frame.origin.x + translation.x, y: self.button.frame.origin.y + translation.y, width: Constants.buttonWidth, height: Constants.buttonHeight)) // Move the delete button according to the fab button let ratio: CGFloat = 0.1 var tempDeleteCenter = self.deleteView.center tempDeleteCenter.x += translation.x * ratio let yPos = tempDeleteCenter.y + translation.y * ratio let yMaxPos = UIScreen.main.bounds.size.height - Constants.offset - self.deleteView.bounds.size.height / 2 tempDeleteCenter.y = yPos > yMaxPos ? yMaxPos : yPos // Check if the fab intersect with delete button let diffX = Constants.deleteWidth - (Constants.buttonWidth / 2) let diffY = Constants.deleteHeight - (Constants.buttonHeight / 2) let deleteZone = self.deleteView.frame.insetBy(dx: diffX, dy: diffY) var deleteTmpNewButtonOrigin = newButtonView.frame.origin deleteTmpNewButtonOrigin.x += (Constants.buttonWidth / 2) deleteTmpNewButtonOrigin.y += (Constants.buttonHeight / 2) let isIn = deleteZone.contains(newButtonView.frame.origin) if isIn { var tempNewButtonDeleteCenter = tempDeleteCenter tempNewButtonDeleteCenter.x -= (Constants.buttonWidth / 2) tempNewButtonDeleteCenter.y -= (Constants.buttonHeight / 2) newButtonView.frame.origin = tempNewButtonDeleteCenter } self.button.frame = newButtonView.frame self.deleteView.center = tempDeleteCenter // Calculate the final fab position during the drag self.button.center = isIn ? self.deleteView.center : newButtonView.center } if panner.state == .ended || panner.state == .cancelled { // The delete zone has to be calculated before reseting the delete view position ! let diffX = Constants.deleteWidth - Constants.buttonWidth let diffY = Constants.deleteHeight - Constants.buttonHeight let deleteZone = self.deleteView.frame.insetBy(dx: diffX, dy: diffY) // Now, we can safely update the delete view position // (not in animation, otherwise it does not work) self.deleteView.center = CGPoint(x: self.deletingPoint.x, y: self.deletingPoint.y) UIView.animate(withDuration: Constants.animationDuration) { self.snapButtonToSocket(deleteZone: deleteZone) self.hideDeleteView() } } } func removeWindow() { window.windowLevel = UIWindow.Level(rawValue: -1) window.isHidden = true } func showWindow() { window.windowLevel = UIWindow.Level(rawValue: CGFloat.greatestFiniteMagnitude) window.isHidden = false } } // MARK: - PodcastPlayerObserver extension FloatingButtonController: PodcastPlayerObserver { func podcastPlayer(_ player: AVPlayer?, didStartPlaying item: AVPlayerItem?) { if let item = PlayerManager.shared.podcastPlayer.currentItem, PlayerManager.shared.isPodcastPlaying(), item.isPlaybackLikelyToKeepUp { self.playAnimation() } } func podcastPlayer(_ player: AVPlayer?, didPausePlaybackOf item: AVPlayerItem?) { self.stopAnimation() } func podcastPlayer(_ player: AVPlayer?, isLoading: Bool, item: AVPlayerItem?) { self.updateLoadingState(isLoading: isLoading) } } // MARK: - UIWindow private class FloatingButtonWindow: UIWindow { var button: UIView? init() { super.init(frame: UIScreen.main.bounds) backgroundColor = nil } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) } fileprivate override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { guard let button = button else { return false } let buttonPoint = convert(point, to: button) return button.point(inside: buttonPoint, with: event) } } // MARK: - Constants extension FloatingButtonController { struct Constants { static let offset: CGFloat = 20 static let buttonWidth: CGFloat = 60 static let buttonHeight: CGFloat = 60 static let insideHeight: CGFloat = 50 static let insideWidth: CGFloat = 50 static let deleteHeight: CGFloat = 50 static let deleteWidth: CGFloat = 50 static let animationDuration: TimeInterval = 0.3 } } This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,6 @@ import Foundation protocol FloatingButtonDelegate: AnyObject { func onSmallPlayerTapped() func closePodcastPlayer() } -
FlorianHardyDev renamed this gist
Mar 19, 2021 . 1 changed file with 0 additions and 0 deletions.There are no files selected for viewing
File renamed without changes. -
FlorianHardyDev created this gist
Mar 16, 2021 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,696 @@ 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) } }