// // AudioPlayer.swift // App // // Created by Rasmus Krämer on 07.03.22. // import Foundation import AVFoundation import UIKit import MediaPlayer import RealmSwift enum PlayMethod: Int { case directplay = 0 case directstream = 1 case transcode = 2 case local = 3 } enum PlayerStatus: Int { case uninitialized = -1 case paused = 0 case playing = 1 } class AudioPlayer: NSObject { internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue") internal let logger = AppLogger(category: "AudioPlayer") private var status: PlayerStatus internal var rate: Float private var tmpRate: Float = 1.0 private var playerContext = 0 private var playerItemContext = 0 internal var playWhenReady: Bool private var initialPlaybackRate: Float internal var audioPlayer: AVQueuePlayer private var sessionId: String private var timeObserverToken: Any? private var sleepTimerObserverToken: Any? private var queueObserver:NSKeyValueObservation? private var queueItemStatusObserver:NSKeyValueObservation? // Sleep timer values internal var sleepTimeChapterStopAt: Double? internal var sleepTimeChapterToken: Any? internal var sleepTimer: Timer? internal var sleepTimeRemaining: Double? internal var currentTrackIndex = 0 private var allPlayerItems:[AVPlayerItem] = [] // MARK: - Constructor init(sessionId: String, playWhenReady: Bool = false, playbackRate: Float = 1) { self.playWhenReady = playWhenReady self.initialPlaybackRate = playbackRate self.audioPlayer = AVQueuePlayer() self.audioPlayer.automaticallyWaitsToMinimizeStalling = true self.sessionId = sessionId self.status = .uninitialized self.rate = 0.0 self.tmpRate = playbackRate super.init() initAudioSession() setupRemoteTransportControls() let playbackSession = self.getPlaybackSession() guard let playbackSession = playbackSession else { logger.error("Failed to fetch playback session. Player will not initialize") NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil) return } // Listen to player events self.setupAudioSessionNotifications() self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext) self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) for track in playbackSession.audioTracks { // TODO: All of this to get the ino of the file on the server. Future server release will include the ino with the session tracks var audioFileIno = "" let trackPath = track.metadata?.path ?? "" if (!playbackSession.isLocal && playbackSession.episodeId != nil) { let episodes = playbackSession.libraryItem?.media?.episodes ?? List() let matchingEpisode:PodcastEpisode? = episodes.first(where: { $0.audioFile?.metadata?.path == trackPath }) audioFileIno = matchingEpisode?.audioFile?.ino ?? "" } else if (!playbackSession.isLocal) { let audioFiles = playbackSession.libraryItem?.media?.audioFiles ?? List() let matchingAudioFile = audioFiles.first(where: { $0.metadata?.path == trackPath }) audioFileIno = matchingAudioFile?.ino ?? "" } if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track, ino: audioFileIno) { let playerItem = AVPlayerItem(asset: playerAsset) if (playbackSession.playMethod == PlayMethod.transcode.rawValue) { playerItem.preferredForwardBufferDuration = 50 } self.allPlayerItems.append(playerItem) } } self.currentTrackIndex = getItemIndexForTime(time: playbackSession.currentTime) logger.log("Starting track index \(self.currentTrackIndex) for start time \(playbackSession.currentTime)") let playerItems = self.allPlayerItems[self.currentTrackIndex.. Bool { return self.status != .uninitialized } public func getPlaybackSession() -> PlaybackSession? { return Database.shared.getPlaybackSession(id: self.sessionId) } private func getItemIndexForTime(time:Double) -> Int { guard let playbackSession = self.getPlaybackSession() else { return 0 } for index in 0.. 0.0 && rate != self.tmpRate && !(observed && rate == 1) if self.audioPlayer.rate != rate { logger.log("setPlaybakRate rate changed from \(self.audioPlayer.rate) to \(rate)") DispatchQueue.runOnMainQueue { self.audioPlayer.rate = rate } } self.rate = rate self.updateNowPlaying() if playbackSpeedChanged { self.tmpRate = rate // Setup the time observer again at the new rate self.setupTimeObservers() } } public func getCurrentTime() -> Double? { guard let playbackSession = self.getPlaybackSession() else { return nil } let currentTrackTime = self.audioPlayer.currentTime().seconds let audioTrack = playbackSession.audioTracks[currentTrackIndex] let startOffset = audioTrack.startOffset ?? 0.0 return startOffset + currentTrackTime } public func getPlayMethod() -> Int? { guard let playbackSession = self.getPlaybackSession() else { return nil } return playbackSession.playMethod } public func getPlaybackSessionId() -> String { return self.sessionId } public func getDuration() -> Double? { guard let playbackSession = self.getPlaybackSession() else { return nil } return playbackSession.duration } public func isPlaying() -> Bool { return self.status == .playing } public func getPlayerState() -> PlayerState { switch status { case .uninitialized: return PlayerState.buffering case .paused, .playing: return PlayerState.ready } } // MARK: - Private private func createAsset(itemId:String, track:AudioTrack, ino:String) -> AVAsset? { guard let playbackSession = self.getPlaybackSession() else { return nil } if (playbackSession.playMethod == PlayMethod.directplay.rawValue) { let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)" let url = URL(string: urlstr)! return AVURLAsset(url: url) } else if (playbackSession.playMethod == PlayMethod.local.rawValue) { guard let localFile = track.getLocalFile() else { // Worst case we can stream the file logger.log("Unable to play local file. Resulting to streaming \(track.localFileId ?? "Unknown")") let urlstr = "\(Store.serverConfig!.address)/api/items/\(itemId)/file/\(ino)?token=\(Store.serverConfig!.token)" let url = URL(string: urlstr)! return AVURLAsset(url: url) } return AVURLAsset(url: localFile.contentPath) } else { // HLS Transcode let headers: [String: String] = [ "Authorization": "Bearer \(Store.serverConfig!.token)" ] return AVURLAsset(url: URL(string: "\(Store.serverConfig!.address)\(track.contentUrl ?? "")")!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) } } private func initAudioSession() { do { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio) } catch { logger.error("Failed to set AVAudioSession category") logger.error(error) } } private func markAudioSessionAs(active: Bool) { do { try AVAudioSession.sharedInstance().setActive(active) } catch { logger.error("Failed to set audio session as active=\(active)") } } // MARK: - iOS audio session notifications @objc private func handleInteruption(notification: Notification) { guard let userInfo = notification.userInfo, let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return } switch type { case .ended: guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) if options.contains(.shouldResume) { self.play(allowSeekBack: true) } default: () } } @objc private func handleRouteChange(notification: Notification) { guard let userInfo = notification.userInfo, let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return } switch reason { case .newDeviceAvailable: // New device found. let session = AVAudioSession.sharedInstance() let headphonesConnected = hasHeadphones(in: session.currentRoute) if headphonesConnected { // We should just let things be, as it's okay to go from speaker to headphones } case .oldDeviceUnavailable: // Old device removed. if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { let headphonesWereConnected = hasHeadphones(in: previousRoute) if headphonesWereConnected { // Removing headphones we should pause instead of keeping on playing self.pause() } } default: () } } private func hasHeadphones(in routeDescription: AVAudioSessionRouteDescription) -> Bool { // Filter the outputs to only those with a port type of headphones. return !routeDescription.outputs.filter({$0.portType == .headphones}).isEmpty } // MARK: - Now playing private func setupRemoteTransportControls() { DispatchQueue.runOnMainQueue { UIApplication.shared.beginReceivingRemoteControlEvents() } let commandCenter = MPRemoteCommandCenter.shared() let deviceSettings = Database.shared.getDeviceSettings() let jumpForwardTime = deviceSettings.jumpForwardTime let jumpBackwardsTime = deviceSettings.jumpBackwardsTime commandCenter.playCommand.isEnabled = true commandCenter.playCommand.addTarget { [weak self] event in if (self!.isPlaying()) { self?.pause() } else { self?.play(allowSeekBack: true) } return .success } commandCenter.pauseCommand.isEnabled = true commandCenter.pauseCommand.addTarget { [weak self] event in if (self!.isPlaying()) { self?.pause() } else { self?.play(allowSeekBack: true) } return .success } commandCenter.togglePlayPauseCommand.isEnabled = true commandCenter.togglePlayPauseCommand.addTarget { [weak self] event in if (self!.isPlaying()) { self?.pause() } else { self?.play(allowSeekBack: true) } return .success } commandCenter.skipForwardCommand.isEnabled = true commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: jumpForwardTime)] commandCenter.skipForwardCommand.addTarget { [weak self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent } guard let currentTime = self?.getCurrentTime() else { return .commandFailed } self?.seek(currentTime + command.preferredIntervals[0].doubleValue, from: "remote") return .success } commandCenter.skipBackwardCommand.isEnabled = true commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: jumpBackwardsTime)] commandCenter.skipBackwardCommand.addTarget { [weak self] event in guard let command = event.command as? MPSkipIntervalCommand else { return .noSuchContent } guard let currentTime = self?.getCurrentTime() else { return .commandFailed } self?.seek(currentTime - command.preferredIntervals[0].doubleValue, from: "remote") return .success } commandCenter.nextTrackCommand.isEnabled = true commandCenter.nextTrackCommand.addTarget { [weak self] _ in guard let currentTime = self?.getCurrentTime() else { return .commandFailed } self?.seek(currentTime + Double(jumpForwardTime), from: "remote") return .success } commandCenter.previousTrackCommand.isEnabled = true commandCenter.previousTrackCommand.addTarget { [weak self] _ in guard let currentTime = self?.getCurrentTime() else { return .commandFailed } self?.seek(currentTime - Double(jumpBackwardsTime), from: "remote") return .success } commandCenter.changePlaybackPositionCommand.isEnabled = true commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .noSuchContent } self?.seek(event.positionTime, from: "remote") return .success } commandCenter.changePlaybackRateCommand.isEnabled = true commandCenter.changePlaybackRateCommand.supportedPlaybackRates = [0.5, 0.75, 1.0, 1.25, 1.5, 2] commandCenter.changePlaybackRateCommand.addTarget { [weak self] event in guard let event = event as? MPChangePlaybackRateCommandEvent else { return .noSuchContent } self?.setPlaybackRate(event.playbackRate) return .success } } private func updateNowPlaying() { NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) if let duration = self.getDuration(), let currentTime = self.getCurrentTime() { NowPlayingInfo.shared.update(duration: duration, currentTime: currentTime, rate: rate) } } // MARK: - Observer public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { if context == &playerContext { if keyPath == #keyPath(AVPlayer.rate) { logger.log("playerContext observer player rate") self.setPlaybackRate(change?[.newKey] as? Float ?? 1.0, observed: true) } else if keyPath == #keyPath(AVPlayer.currentItem) { NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.update.rawValue), object: nil) logger.log("WARNING: Item ended") } } else { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) return } } public static var instance: AudioPlayer? }