diff --git a/ios/App/App/AudioPlayer.swift b/ios/App/App/AudioPlayer.swift new file mode 100644 index 00000000..76bb3453 --- /dev/null +++ b/ios/App/App/AudioPlayer.swift @@ -0,0 +1,258 @@ +// +// AudioPlayer.swift +// App +// +// Created by Rasmus Krämer on 07.03.22. +// + +import Foundation +import AVFoundation +import UIKit +import MediaPlayer + +func getData(from url: URL, completion: @escaping (UIImage?) -> Void) { + URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in + if let data = data { + completion(UIImage(data:data)) + } + }).resume() +} + +class AudioPlayer: NSObject { + // enums and @objc are not compatible + @objc dynamic var status: Int + @objc dynamic var rate: Float + + private var playerContext = 0 + private var playerItemContext = 0 + private var nowPlayingInfo: [String: Any] = [:] + + private var playWhenReady: Bool + + private var audioPlayer: AVPlayer + public var audiobook: Audiobook + + init(audiobook: Audiobook, playWhenReady: Bool = false) { + self.playWhenReady = playWhenReady + self.audiobook = audiobook + self.audioPlayer = AVPlayer() + self.status = -1 + self.rate = 0.0 + + super.init() + + initAudioSession() + setupRemoteTransportControls() + invokeMetadataUpdate() + + // Listen to player events + self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.rate), options: .new, context: &playerContext) + self.audioPlayer.addObserver(self, forKeyPath: #keyPath(AVPlayer.currentItem), options: .new, context: &playerContext) + + let playerItem = AVPlayerItem(asset: createAsset()) + playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: .new, context: &playerItemContext) + + self.audioPlayer.replaceCurrentItem(with: playerItem) + seek(self.audiobook.startTime) + + NSLog("Audioplayer ready") + } + deinit { + destroy() + } + func destroy() { + pause() + + nowPlayingInfo = [:] + updateNowPlaying() + } + + // MARK: - Methods + public func play() { + self.audioPlayer.play() + self.status = 1 + self.rate = 1.0 + + updateNowPlaying() + } + public func pause() { + self.audioPlayer.pause() + self.status = 0 + self.rate = 0.0 + + updateNowPlaying() + } + public func seek(_ to: Double) { + let continuePlaing = rate > 0.0 + + pause() + self.audioPlayer.seek(to: CMTime(seconds: to, preferredTimescale: 1000)) { completed in + if !completed { + NSLog("WARNING: seeking not completed (to \(to)") + } + + if continuePlaing { + self.play() + } + self.updateNowPlaying() + } + } + public func setPlaybackRate(_ rate: Float) { + if(self.audioPlayer.rate != rate) { + self.audioPlayer.rate = rate + } + self.rate = rate + + self.updateNowPlaying() + } + + public func getCurrentTime() -> Double { + self.audioPlayer.currentTime().seconds + } + public func getDuration() -> Double { + self.audioPlayer.currentItem?.duration.seconds ?? 0 + } + + // MARK: - Private + private func createAsset() -> AVAsset { + let headers: [String: String] = [ + "Authorization": "Bearer \(audiobook.token)" + ] + + return AVURLAsset(url: URL(string: audiobook.playlistUrl)!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) + } + private func initAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay]) + try AVAudioSession.sharedInstance().setActive(true) + } catch { + NSLog("Failed to set AVAudioSession category") + print(error) + } + } + + private func shouldFetchCover() -> Bool { + nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != audiobook.streamId || nowPlayingInfo[MPMediaItemPropertyArtwork] == nil + } + + // MARK: - Now playing + func setupRemoteTransportControls() { + let commandCenter = MPRemoteCommandCenter.shared() + + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.addTarget { [unowned self] event in + play() + return .success + } + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.addTarget { [unowned self] event in + pause() + return .success + } + + commandCenter.skipForwardCommand.isEnabled = true + commandCenter.skipForwardCommand.preferredIntervals = [30] + commandCenter.skipForwardCommand.addTarget { [unowned self] event in + guard let command = event.command as? MPSkipIntervalCommand else { + return .noSuchContent + } + + seek(getCurrentTime() + command.preferredIntervals[0].doubleValue) + return .success + } + commandCenter.skipBackwardCommand.isEnabled = true + commandCenter.skipBackwardCommand.preferredIntervals = [30] + commandCenter.skipBackwardCommand.addTarget { [unowned self] event in + guard let command = event.command as? MPSkipIntervalCommand else { + return .noSuchContent + } + + seek(getCurrentTime() - command.preferredIntervals[0].doubleValue) + return .success + } + + commandCenter.changePlaybackPositionCommand.isEnabled = true + commandCenter.changePlaybackPositionCommand.addTarget { event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { + return .noSuchContent + } + + self.seek(event.positionTime) + return .success + } + } + + func invokeMetadataUpdate() { + if !shouldFetchCover() && audiobook.artworkUrl != nil { + setMetadata(nil) + return + } + + guard let url = URL(string: audiobook.artworkUrl!) else { return } + getData(from: url) { [weak self] image in + guard let self = self, + let downloadedImage = image else { + return + } + let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in + return downloadedImage + }) + + self.setMetadata(artwork) + } + } + func setMetadata(_ artwork: MPMediaItemArtwork?) { + if artwork != nil { + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork + } else if shouldFetchCover() { + nowPlayingInfo[MPMediaItemPropertyArtwork] = nil + } + + nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] = audiobook.streamId + nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = URL(string: audiobook.playlistUrl) + nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false + nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = "hls" + + nowPlayingInfo[MPMediaItemPropertyTitle] = audiobook.title + nowPlayingInfo[MPMediaItemPropertyArtist] = audiobook.author ?? "unknown" + nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = audiobook.series + } + + func updateNowPlaying() { + nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = getDuration() + nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = getCurrentTime() + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate + nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 + + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + } + + // MARK: - Observer + public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + if context == &playerItemContext { + if keyPath == #keyPath(AVPlayer.status) { + guard let playerStatus = AVPlayerItem.Status(rawValue: (change?[.newKey] as? Int ?? -1)) else { return } + + if playerStatus == .readyToPlay { + NSLog("pain \(self.audiobook.startTime)") + updateNowPlaying() + + self.status = 0 + if self.playWhenReady { + self.playWhenReady = false + self.play() + } + } + } + } else if context == &playerContext { + if keyPath == #keyPath(AVPlayer.rate) { + setPlaybackRate(change?[.newKey] as? Float ?? 1.0) + } else if keyPath == #keyPath(AVPlayer.currentItem) { + NSLog("WARNING: Item ended") + } + } else { + super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) + return + } + } +} diff --git a/ios/App/App/Audiobook.swift b/ios/App/App/Audiobook.swift new file mode 100644 index 00000000..e67465a0 --- /dev/null +++ b/ios/App/App/Audiobook.swift @@ -0,0 +1,24 @@ +// +// AudioBook.swift +// App +// +// Created by Rasmus Krämer on 07.03.22. +// + +import Foundation + +struct Audiobook { + var streamId: String + var audiobookId: String + var playlistUrl: String + + var startTime: Double = 0.0 + var duration: Double + + var title: String + var series: String? + var author: String? + var artworkUrl: String? + + var token: String +} diff --git a/ios/App/App/MyNativeAudio.swift b/ios/App/App/MyNativeAudio.swift index 7eb1b8f0..832fc3bd 100644 --- a/ios/App/App/MyNativeAudio.swift +++ b/ios/App/App/MyNativeAudio.swift @@ -3,369 +3,177 @@ import Capacitor import MediaPlayer import AVKit -struct Audiobook { - var streamId = "" - var audiobookId = "" - var title = "No Title" - var author = "Unknown" - var playWhenReady = false - var startTime = 0.0 - var cover = "" - var duration = 0 - var series = "" - var playlistUrl = "" - var token = "" -} - @objc(MyNativeAudio) public class MyNativeAudio: CAPPlugin { var currentCall: CAPPluginCall? - var audioPlayer: AVPlayer! - var audiobook: Audiobook? + var currentPlayer: AudioPlayer? - // Key-value observing context - private var playerItemContext = 0 - private var playerState: PlayerState = .stopped - - enum PlayerState { - case stopped - case playing - case paused - } + var playerContext = 0 override public func load() { NSLog("Load MyNativeAudio") - NotificationCenter.default.addObserver(self, selector: #selector(stop), name: Notification.Name.AVPlayerItemDidPlayToEndTime, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(appDidEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) - - setupRemoteTransportControls() + // NotificationCenter.default.addObserver(self, selector: #selector(stop), name: Notification.Name.AVPlayerItemDidPlayToEndTime, object: nil) } + @objc func initPlayer(_ call: CAPPluginCall) { NSLog("Init Player") - audiobook = Audiobook( - streamId: call.getString("id") ?? "", - audiobookId: call.getString("audiobookId") ?? "", + let audiobook = Audiobook( + streamId: call.getString("id")!, + audiobookId: call.getString("audiobookId")!, + playlistUrl: call.getString("playlistUrl")!, + + startTime: (Double(call.getString("startTime") ?? "0") ?? 0.0) / 1000, + duration: call.getDouble("duration") ?? 0, + title: call.getString("title") ?? "No Title", - author: call.getString("author") ?? "Unknown", - playWhenReady: call.getBool("playWhenReady", false), - startTime: Double(call.getString("startTime") ?? "0") ?? 0.0, - cover: call.getString("cover") ?? "", - duration: call.getInt("duration") ?? 0, - series: call.getString("series") ?? "", - playlistUrl: call.getString("playlistUrl") ?? "", + series: call.getString("series"), + author: call.getString("author"), + artworkUrl: call.getString("cover"), + token: call.getString("token") ?? "" ) - if (audiobook == nil) { + + if self.currentPlayer != nil && self.currentPlayer?.audiobook.streamId == audiobook.streamId { + call.resolve(["success": true]) return } - - let headers: [String:String] = [ - "Authorization": "Bearer \(audiobook!.token)" - ] - let url = URL(string:audiobook!.playlistUrl) - let asset = AVURLAsset( - url: url!, - options: ["AVURLAssetHTTPHeaderFieldsKey": headers] - ) - print("Playing audiobook url \(String(describing: url))") - - // For play in background - do { - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio, options: [.allowAirPlay]) - try AVAudioSession.sharedInstance().setActive(true) - NSLog("[TEST] Session is Active") - } catch { - NSLog("[TEST] Failed to set BG Data") - print(error) - } - - let playerItem = AVPlayerItem(asset: asset) - playerItem.addObserver(self, forKeyPath: #keyPath(AVPlayerItem.status), options: [.old, .new], context: &playerItemContext) - - self.audioPlayer = AVPlayer(playerItem: playerItem) - seek(to: (audiobook?.startTime ?? 0.0) / 1000) - - let time = self.audioPlayer.currentItem?.currentTime() - print("Audio Player Initialized \(String(describing: time))") + self.currentPlayer = AudioPlayer(audiobook: audiobook, playWhenReady: call.getBool("playWhenReady", false)) + self.currentPlayer!.addObserver(self, forKeyPath: #keyPath(AudioPlayer.status), options: .new, context: &playerContext) call.resolve(["success": true]) } public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { - // Only handle observations for the playerItemContext - guard context == &playerItemContext else { + guard context == &playerContext else { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) return } - if keyPath == #keyPath(AVPlayerItem.status) { - let status: AVPlayerItem.Status - if let statusNumber = change?[.newKey] as? NSNumber { - status = AVPlayerItem.Status(rawValue: statusNumber.intValue)! - print("AVPlayer Status Change \(String(status.rawValue))") - } else { - status = .unknown - } - - // Switch over status value - switch status { - case .readyToPlay: - NSLog("AVPlayer ready to play") - - setNowPlayingMetadata() - sendMetadata() - - if (audiobook?.playWhenReady == true) { - NSLog("AVPlayer playWhenReady == true") - play() - } - break - case .failed: - // Player item failed. See error. - break - case .unknown: - // Player item is not yet ready - break - @unknown default: - break - } + if keyPath == #keyPath(AudioPlayer.status) { + sendMetadata() } } @objc func seekForward(_ call: CAPPluginCall) { - let amount = (Double(call.getString("amount", "0")) ?? 0) / 1000 - let destinationTime = getCurrentTime() + amount + if self.currentPlayer == nil { + call.resolve() + return + } - seek(to: destinationTime) + let amount = (Double(call.getString("amount", "0")) ?? 0) / 1000 + let destinationTime = self.currentPlayer!.getCurrentTime() + amount + + self.currentPlayer!.seek(destinationTime) call.resolve() } @objc func seekBackward(_ call: CAPPluginCall) { - let amount = (Double(call.getString("amount", "0")) ?? 0) / 1000 - let destinationTime = getCurrentTime() - amount + if self.currentPlayer == nil { + call.resolve() + return + } - seek(to: destinationTime) + let amount = (Double(call.getString("amount", "0")) ?? 0) / 1000 + let destinationTime = self.currentPlayer!.getCurrentTime() - amount + + self.currentPlayer!.seek(destinationTime) call.resolve() } @objc func seekPlayer(_ call: CAPPluginCall) { + if self.currentPlayer == nil { + call.resolve() + return + } + let seekTime = (Double(call.getString("timeMs", "0")) ?? 0) / 1000 NSLog("Seek Player \(seekTime)") - seek(to: seekTime) + self.currentPlayer!.seek(seekTime) call.resolve() } @objc func pausePlayer(_ call: CAPPluginCall) { - pause() + if self.currentPlayer == nil { + call.resolve() + return + } + + self.currentPlayer!.pause() + + sendPlaybackStatusUpdate(false) call.resolve() } @objc func playPlayer(_ call: CAPPluginCall) { - play() + if self.currentPlayer == nil { + call.resolve() + return + } + + self.currentPlayer!.play() + + sendPlaybackStatusUpdate(true) call.resolve() } @objc func terminateStream(_ call: CAPPluginCall) { - pause() + stop() call.resolve() } @objc func stop() { if let call = currentCall { + if self.currentPlayer != nil { + self.currentPlayer!.destroy() + } + + self.currentPlayer = nil currentCall = nil; call.resolve([ "result": true ]) } } @objc func getCurrentTime(_ call: CAPPluginCall) { - let currTime = self.audioPlayer.currentItem?.currentTime().seconds ?? 0 - let buffTime = self.audioPlayer.currentItem?.currentTime().seconds ?? 0 + if self.currentPlayer == nil { + call.resolve() + return + } - NSLog("AVPlayer getCurrentTime \(currTime)") - call.resolve([ "value": currTime * 1000, "bufferedTime": buffTime * 1000 ]) + let currentTime = self.currentPlayer?.getCurrentTime() ?? 0 + call.resolve([ "value": currentTime * 1000, "bufferedTime": currentTime * 1000 ]) } @objc func getStreamSyncData(_ call: CAPPluginCall) { - let streamId = audiobook?.streamId ?? "" - call.resolve([ "isPlaying": false, "lastPauseTime": 0, "id": streamId ]) + if self.currentPlayer == nil { + call.resolve([ "isPlaying": false as Any, "lastPauseTime": 0, "id": nil ]) + return + } + + call.resolve([ "isPlaying": self.currentPlayer!.rate > 0.0, "lastPauseTime": 0, "id": self.currentPlayer?.audiobook.streamId as Any ]) } @objc func setPlaybackSpeed(_ call: CAPPluginCall) { + if self.currentPlayer == nil { + call.resolve() + return + } + let speed = call.getFloat("speed") ?? 0 - NSLog("[TEST] Set Playback Speed \(speed)") - audioPlayer.rate = speed + self.currentPlayer!.setPlaybackRate(speed) call.resolve() } - - func play() { - audioPlayer.play() - playerState = .playing - - updateNowPlaying() - sendMetadata() - - self.notifyListeners("onPlayingUpdate", data: [ - "value": true - ]) - } - func pause() { - audioPlayer.pause() - playerState = .paused - - updateNowPlaying() - sendMetadata() - - self.notifyListeners("onPlayingUpdate", data: [ - "value": false - ]) - } - func seek(to: Double) { - var seekTime = to - - if seekTime < 0 { - seekTime = 0 - } else if seekTime > getDuration() { - seekTime = getDuration() - } - - self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { finished in - self.updateNowPlaying() - } - } - - func getCurrentTime() -> Double { - return self.audioPlayer.currentItem?.currentTime().seconds ?? 0 - } - func getDuration() -> Double { - return self.audioPlayer.currentItem?.duration.seconds ?? 0 - } - func getPlaybackRate() -> Float { - return self.audioPlayer.rate - } func sendMetadata() { + if self.currentPlayer == nil { + return + } + self.notifyListeners("onMetadata", data: [ - "duration": getDuration() * 1000, - "currentTime": getCurrentTime() * 1000, + "duration": self.currentPlayer!.getDuration() * 1000, + "currentTime": self.currentPlayer!.getCurrentTime() * 1000, "stateName": "unknown" ]) } - - @objc func appDidEnterBackground() { - updateNowPlaying() - NSLog("[TEST] App Enter Backround") - } - @objc func appWillEnterForeground() { - NSLog("[TEST] App Will Enter Foreground") - } - - func getData(from url: URL, completion: @escaping (UIImage?) -> Void) { - URLSession.shared.dataTask(with: url, completionHandler: {(data, response, error) in - if let data = data { - completion(UIImage(data:data)) - } - }) - .resume() - } - func shouldFetchCover() -> Bool { - let nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]() - return nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] as? String != audiobook?.streamId - } - - func setupRemoteTransportControls() { - let commandCenter = MPRemoteCommandCenter.shared() - - commandCenter.playCommand.isEnabled = true - commandCenter.playCommand.addTarget { [unowned self] event in - play() - return .success - } - commandCenter.pauseCommand.isEnabled = true - commandCenter.pauseCommand.addTarget { [unowned self] event in - pause() - return .success - } - - commandCenter.skipForwardCommand.isEnabled = true - commandCenter.skipForwardCommand.preferredIntervals = [30] - commandCenter.skipForwardCommand.addTarget { [unowned self] event in - guard let command = event.command as? MPSkipIntervalCommand else { - return .noSuchContent - } - - seek(to: getCurrentTime() + command.preferredIntervals[0].doubleValue) - return .success - } - commandCenter.skipBackwardCommand.isEnabled = true - commandCenter.skipBackwardCommand.preferredIntervals = [30] - commandCenter.skipBackwardCommand.addTarget { [unowned self] event in - guard let command = event.command as? MPSkipIntervalCommand else { - return .noSuchContent - } - - seek(to: getCurrentTime() - command.preferredIntervals[0].doubleValue) - return .success - } - - commandCenter.changePlaybackPositionCommand.isEnabled = true - commandCenter.changePlaybackPositionCommand.addTarget { event in - guard let event = event as? MPChangePlaybackPositionCommandEvent else { - return .noSuchContent - } - - self.seek(to: event.positionTime) - return .success - } - } - func updateNowPlaying() { - NSLog("%@", "**** Set playback info: rate \(getPlaybackRate()), position \(getCurrentTime()), duration \(getDuration())") - - let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() - var nowPlayingInfo = nowPlayingInfoCenter.nowPlayingInfo ?? [String: Any]() - - nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = getDuration() - nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = getCurrentTime() - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = getPlaybackRate() - nowPlayingInfo[MPNowPlayingInfoPropertyDefaultPlaybackRate] = 1.0 - - nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo - } - - func setNowPlayingMetadata() { - if audiobook?.cover != nil && shouldFetchCover() { - guard let url = URL(string: audiobook!.cover) else { return } - getData(from: url) { [weak self] image in - guard let self = self, - let downloadedImage = image else { - return - } - let artwork = MPMediaItemArtwork.init(boundsSize: downloadedImage.size, requestHandler: { _ -> UIImage in - return downloadedImage - }) - - self.setNowPlayingMetadataWithImage(artwork) - } - } else { - setNowPlayingMetadataWithImage(nil) - } - } - func setNowPlayingMetadataWithImage(_ artwork: MPMediaItemArtwork?) { - NSLog("%@", "**** Set track metadata: title \(audiobook?.title ?? "")") - - let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default() - var nowPlayingInfo = [String: Any]() - - if artwork != nil { - nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork - } else if shouldFetchCover() { - nowPlayingInfo[MPMediaItemPropertyArtwork] = nil - } - - nowPlayingInfo[MPNowPlayingInfoPropertyExternalContentIdentifier] = audiobook?.streamId - nowPlayingInfo[MPNowPlayingInfoPropertyAssetURL] = audiobook?.playlistUrl != nil ? URL(string: audiobook!.playlistUrl) : nil - nowPlayingInfo[MPNowPlayingInfoPropertyMediaType] = "hls" - nowPlayingInfo[MPNowPlayingInfoPropertyIsLiveStream] = false - nowPlayingInfo[MPMediaItemPropertyTitle] = audiobook?.title ?? "" - nowPlayingInfo[MPMediaItemPropertyArtist] = audiobook?.author ?? "" - - nowPlayingInfoCenter.nowPlayingInfo = nowPlayingInfo + func sendPlaybackStatusUpdate(_ playing: Bool) { + self.notifyListeners("onPlayingUpdate", data: [ + "value": playing + ]) } }