Skip to content

Instantly share code, notes, and snippets.

@FlorianHardyDev
Last active June 6, 2022 02:45
Show Gist options
  • Save FlorianHardyDev/3cf5863aa83e26d493edfe6fcb48ab24 to your computer and use it in GitHub Desktop.
Save FlorianHardyDev/3cf5863aa83e26d493edfe6fcb48ab24 to your computer and use it in GitHub Desktop.

Revisions

  1. FlorianHardyDev revised this gist Mar 19, 2021. 2 changed files with 417 additions and 0 deletions.
    411 changes: 411 additions & 0 deletions FloatingButtonController.swift
    Original 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
    }
    }
    6 changes: 6 additions & 0 deletions FloatingButtonDelegate.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,6 @@
    import Foundation

    protocol FloatingButtonDelegate: AnyObject {
    func onSmallPlayerTapped()
    func closePodcastPlayer()
    }
  2. FlorianHardyDev renamed this gist Mar 19, 2021. 1 changed file with 0 additions and 0 deletions.
    File renamed without changes.
  3. FlorianHardyDev created this gist Mar 16, 2021.
    696 changes: 696 additions & 0 deletions PlayerManager.txt
    Original 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)
    }
    }