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