advplyr.audiobookshelf-app/ios/App/Shared/player/AudioPlayer.swift
2023-02-19 17:42:45 +01:00

688 lines
28 KiB
Swift

//
// AudioPlayer.swift
// App
//
// Created by Rasmus Krämer on 07.03.22.
//
import Foundation
import AVFoundation
import UIKit
import MediaPlayer
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
private 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] = []
private var pausedTimer: Timer?
// 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 {
if let playerAsset = createAsset(itemId: playbackSession.libraryItemId!, track: track) {
let playerItem = AVPlayerItem(asset: playerAsset)
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..<self.allPlayerItems.count]
logger.log("Setting player items \(playerItems.count)")
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
setupTimeObservers()
setupQueueObserver()
setupQueueItemStatusObserver()
logger.log("Audioplayer ready")
}
deinit {
self.queueObserver?.invalidate()
self.queueItemStatusObserver?.invalidate()
}
public func destroy() {
// Pause is not synchronous causing this error on below lines:
// AVAudioSession_iOS.mm:1206 Deactivating an audio session that has running I/O. All I/O should be stopped or paused prior to deactivating the audio session
// It is related to L79 `AVAudioSession.sharedInstance().setActive(false)`
self.pause()
self.audioPlayer.replaceCurrentItem(with: nil)
do {
try AVAudioSession.sharedInstance().setActive(false)
} catch {
logger.error("Failed to set AVAudioSession inactive")
logger.error(error)
}
self.removeAudioSessionNotifications()
DispatchQueue.runOnMainQueue {
UIApplication.shared.endReceivingRemoteControlEvents()
}
// Remove observers
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.rate), context: &playerContext)
self.audioPlayer.removeObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), context: &playerContext)
self.removeTimeObservers()
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.closed.rawValue), object: nil)
// Remove timers
self.stopPausedTimer()
self.removeSleepTimer()
}
public func isInitialized() -> 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..<self.allPlayerItems.count {
let startOffset = playbackSession.audioTracks[index].startOffset ?? 0.0
let duration = playbackSession.audioTracks[index].duration
let trackEnd = startOffset + duration
if (time < trackEnd.rounded(.down)) {
return index
} else if (index == self.allPlayerItems.count - 1) {
// Seeking past end of last item
return index
}
}
return 0
}
private func setupAudioSessionNotifications() {
NotificationCenter.default.addObserver(self, selector: #selector(handleInteruption), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
NotificationCenter.default.addObserver(self, selector: #selector(handleRouteChange), name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
}
private func removeAudioSessionNotifications() {
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
NotificationCenter.default.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: AVAudioSession.sharedInstance())
}
private func setupTimeObservers() {
// Time observer should be configured on the main queue
DispatchQueue.runOnMainQueue {
self.removeTimeObservers()
let timeScale = CMTimeScale(NSEC_PER_SEC)
// Save the current time every 15 seconds
var seconds = 15.0
var time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
self.timeObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
guard let self = self else { return }
guard self.isInitialized() else { return }
guard let currentTime = self.getCurrentTime() else { return }
let isPlaying = self.isPlaying()
Task {
// Let the player update the current playback positions
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: isPlaying, isStopping: false)
}
}
// Update the sleep timer every second
seconds = 1.0
time = CMTime(seconds: Double(seconds), preferredTimescale: timeScale)
self.sleepTimerObserverToken = self.audioPlayer.addPeriodicTimeObserver(forInterval: time, queue: self.queue) { [weak self] time in
guard let self = self else { return }
if self.isSleepTimerSet() {
// Update the UI
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.sleepSet.rawValue), object: nil)
}
}
}
}
private func removeTimeObservers() {
if let timeObserverToken = timeObserverToken {
self.audioPlayer.removeTimeObserver(timeObserverToken)
self.timeObserverToken = nil
}
if let sleepTimerObserverToken = sleepTimerObserverToken {
self.audioPlayer.removeTimeObserver(sleepTimerObserverToken)
self.sleepTimerObserverToken = nil
}
}
private func setupQueueObserver() {
self.queueObserver = self.audioPlayer.observe(\.currentItem, options: [.new]) { [weak self] _,_ in
guard let self = self else { return }
let prevTrackIndex = self.currentTrackIndex
self.audioPlayer.currentItem.map { item in
self.currentTrackIndex = self.allPlayerItems.firstIndex(of:item) ?? 0
if (self.currentTrackIndex != prevTrackIndex) {
self.logger.log("New Current track index \(self.currentTrackIndex)")
}
}
}
}
private func setupQueueItemStatusObserver() {
logger.log("queueStatusObserver: Setting up")
// Listen for player item updates
self.queueItemStatusObserver?.invalidate()
self.queueItemStatusObserver = self.audioPlayer.currentItem?.observe(\.status, options: [.new, .old], changeHandler: { [weak self] playerItem, change in
self?.handleQueueItemStatus(playerItem: playerItem)
})
// Ensure we didn't miss a player item update during initialization
if let playerItem = self.audioPlayer.currentItem {
self.handleQueueItemStatus(playerItem: playerItem)
}
}
private func handleQueueItemStatus(playerItem: AVPlayerItem) {
logger.log("queueStatusObserver: Current item status changed")
guard let playbackSession = self.getPlaybackSession() else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return
}
if (playerItem.status == .readyToPlay) {
logger.log("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
let firstReady = self.status == .uninitialized
if firstReady && !self.playWhenReady {
// Seek is async, and if we call this when also pressing play, we will get weird jumps in the scrub bar depending on timing
// Seeking to the correct position happens during play()
self.seek(playbackSession.currentTime, from: "queueItemStatusObserver")
}
// Start the player, if requested
if self.playWhenReady {
self.playWhenReady = false
self.play(allowSeekBack: false, isInitializing: true)
} else {
// Mark the player as ready
self.status = .paused
}
} else if (playerItem.status == .failed) {
logger.error("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
}
}
private func startPausedTimer() {
guard self.pausedTimer == nil else { return }
self.queue.async {
self.pausedTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] timer in
self?.logger.log("PAUSE TIMER: Syncing from server")
Task { await PlayerProgress.shared.syncFromServer() }
}
}
}
private func stopPausedTimer() {
self.pausedTimer?.invalidate()
self.pausedTimer = nil
}
// MARK: - Methods
public func play(allowSeekBack: Bool = false, isInitializing: Bool = false) {
guard self.isInitialized() || isInitializing else { return }
guard let session = self.getPlaybackSession() else {
NotificationCenter.default.post(name: NSNotification.Name(PlayerEvents.failed.rawValue), object: nil)
return
}
// Determine where we are starting playback
let currentTime = allowSeekBack ? PlayerTimeUtils.calcSeekBackTime(currentTime: session.currentTime, lastPlayedMs: session.updatedAt) : session.currentTime
// Sync our new playback position
Task { await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: false) }
// Start playback, with a seek, for as smooth a scrub bar start as possible
let currentTrackStartOffset = session.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
let seekTime = currentTime - currentTrackStartOffset
DispatchQueue.runOnMainQueue {
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] completed in
guard completed else { return }
self?.resumePlayback()
}
}
}
private func resumePlayback() {
logger.log("PLAY: Resuming playback")
// Stop the paused timer
self.stopPausedTimer()
self.markAudioSessionAs(active: true)
DispatchQueue.runOnMainQueue {
self.audioPlayer.play()
self.audioPlayer.rate = self.tmpRate
}
self.status = .playing
// Update the progress
self.updateNowPlaying()
// Handle a chapter sleep timer that may now be invalid
self.handleTrackChangeForChapterSleepTimer()
}
public func pause() {
guard self.isInitialized() else { return }
logger.log("PAUSE: Pausing playback")
DispatchQueue.runOnMainQueue {
self.audioPlayer.pause()
}
self.markAudioSessionAs(active: false)
Task {
if let currentTime = self.getCurrentTime() {
await PlayerProgress.shared.syncFromPlayer(currentTime: currentTime, includesPlayProgress: self.isPlaying(), isStopping: true)
}
}
self.status = .paused
updateNowPlaying()
self.startPausedTimer()
}
public func seek(_ to: Double, from: String) {
logger.log("SEEK: Seek to \(to) from \(from)")
guard let playbackSession = self.getPlaybackSession() else { return }
let indexOfSeek = getItemIndexForTime(time: to)
logger.log("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
// Reconstruct queue if seeking to a different track
if (self.currentTrackIndex != indexOfSeek) {
self.currentTrackIndex = indexOfSeek
try? playbackSession.update {
playbackSession.currentTime = to
}
let playerItems = self.allPlayerItems[indexOfSeek..<self.allPlayerItems.count]
DispatchQueue.runOnMainQueue {
self.audioPlayer.removeAllItems()
for item in Array(playerItems) {
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
}
}
seekInCurrentTrack(to: to, playbackSession: playbackSession)
setupQueueItemStatusObserver()
} else {
seekInCurrentTrack(to: to, playbackSession: playbackSession)
}
// Only for use in here where we handle track selection
func seekInCurrentTrack(to: Double, playbackSession: PlaybackSession) {
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
let ctso = currentTrack.startOffset ?? 0.0
let trackEnd = ctso + currentTrack.duration
logger.log("SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))")
let boundedTime = min(max(to, ctso), trackEnd)
let seekTime = boundedTime - ctso
DispatchQueue.runOnMainQueue {
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
self?.logger.log("SEEK: Completion handler called")
guard completed else {
self?.logger.log("SEEK: WARNING: seeking not completed (to \(seekTime)")
return
}
guard let self = self else { return }
self.updateNowPlaying()
}
}
}
}
public func setPlaybackRate(_ rate: Float, observed: Bool = false) {
let playbackSpeedChanged = rate > 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) -> AVAsset? {
guard let playbackSession = self.getPlaybackSession() else { return nil }
if (playbackSession.playMethod == PlayMethod.directplay.rawValue) {
// The only reason this is separate is because the filename needs to be encoded
let relPath = track.metadata?.relPath ?? ""
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?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 relPath = track.metadata?.relPath ?? ""
let filepathEncoded = relPath.addingPercentEncoding(withAllowedCharacters: NSCharacterSet.urlQueryAllowed)
let urlstr = "\(Store.serverConfig!.address)/s/item/\(itemId)/\(filepathEncoded ?? "")?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
self?.play(allowSeekBack: true)
return .success
}
commandCenter.pauseCommand.isEnabled = true
commandCenter.pauseCommand.addTarget { [weak self] event in
self?.pause()
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?
}