diff --git a/Example/Podfile.lock b/Example/Podfile.lock index a7283c0..713269a 100644 --- a/Example/Podfile.lock +++ b/Example/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - SwiftAudioPlayer (7.5.0) + - SwiftAudioPlayer (7.6.0) DEPENDENCIES: - SwiftAudioPlayer (from `../`) @@ -9,8 +9,8 @@ EXTERNAL SOURCES: :path: "../" SPEC CHECKSUMS: - SwiftAudioPlayer: f94f6350ba7d658b0bd290ce3b57cbf14139f072 + SwiftAudioPlayer: a546709faf47f3ab0cb59e41ba4432e6bb61db0a PODFILE CHECKSUM: 92c7367b33454536515e31bf5d93e792787f3f4a -COCOAPODS: 1.10.1 +COCOAPODS: 1.11.2 diff --git a/Example/SwiftAudioPlayer/AppDelegate.swift b/Example/SwiftAudioPlayer/AppDelegate.swift index 6355180..ab28d0a 100644 --- a/Example/SwiftAudioPlayer/AppDelegate.swift +++ b/Example/SwiftAudioPlayer/AppDelegate.swift @@ -6,46 +6,41 @@ // Copyright (c) 2019 tanhakabir. All rights reserved. // -import UIKit import SwiftAudioPlayer +import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? - - func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Override point for customization after application launch. return true } - func applicationWillResignActive(_ application: UIApplication) { + func applicationWillResignActive(_: UIApplication) { // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game. } - func applicationDidEnterBackground(_ application: UIApplication) { + func applicationDidEnterBackground(_: UIApplication) { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. } - func applicationWillEnterForeground(_ application: UIApplication) { + func applicationWillEnterForeground(_: UIApplication) { // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background. } - func applicationDidBecomeActive(_ application: UIApplication) { + func applicationDidBecomeActive(_: UIApplication) { // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. } - func applicationWillTerminate(_ application: UIApplication) { + func applicationWillTerminate(_: UIApplication) { // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. } - - func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { + + func application(_: UIApplication, handleEventsForBackgroundURLSession _: String, completionHandler: @escaping () -> Void) { SAPlayer.Downloader.setBackgroundCompletionHandler(completionHandler) } - - } - diff --git a/Example/SwiftAudioPlayer/Model.swift b/Example/SwiftAudioPlayer/Model.swift index c84e336..0188c78 100644 --- a/Example/SwiftAudioPlayer/Model.swift +++ b/Example/SwiftAudioPlayer/Model.swift @@ -11,11 +11,11 @@ import SwiftAudioPlayer struct AudioInfo: Hashable { var index: Int = 0 - + var urls: [URL] = [URL(string: "https://www.fesliyanstudios.com/musicfiles/2019-04-23_-_Trusted_Advertising_-_www.fesliyanstudios.com/15SecVersion2019-04-23_-_Trusted_Advertising_-_www.fesliyanstudios.com.mp3")!, URL(string: "https://chtbl.com/track/18338/traffic.libsyn.com/secure/acquired/acquired_-_armrev_2.mp3?dest-id=376122")!, URL(string: "https://ice6.somafm.com/groovesalad-256-mp3")!] - + var url: URL { switch index { case 0: @@ -28,7 +28,7 @@ struct AudioInfo: Hashable { return urls[0] } } - + var title: String { switch index { case 0: @@ -41,48 +41,44 @@ struct AudioInfo: Hashable { return "Soundbite" } } - + let artist: String = "SwiftAudioPlayer Sample App" - let releaseDate: Int = 1550790640 - + let releaseDate: Int = 1_550_790_640 + var lockscreenInfo: SALockScreenInfo { - get { - return SALockScreenInfo(title: self.title, artist: self.artist, albumTitle: nil, artwork: nil, releaseDate: self.releaseDate) - } + return SALockScreenInfo(title: title, artist: artist, albumTitle: nil, artwork: nil, releaseDate: releaseDate) } - + var savedUrl: URL? { - get { - return savedUrls[index] - } + return savedUrls[index] } - + var savedUrls: [URL?] = [nil, nil, nil] - + mutating func addSavedUrl(_ url: URL) { savedUrls[index] = url } - + mutating func deleteSavedUrl() { savedUrls[index] = nil } - + mutating func addSavedUrl(_ url: URL, atIndex i: Int) { savedUrls[i] = url } - + mutating func deleteSavedUrl(atIndex i: Int) { savedUrls[i] = nil } - + func getUrl(atIndex i: Int) -> URL { return urls[i] } - + mutating func setIndex(_ i: Int) { index = i } - + func getIndex(forURL url: URL) -> Int? { return urls.firstIndex(of: url) ?? savedUrls.firstIndex(of: url) } diff --git a/Example/SwiftAudioPlayer/ViewController.swift b/Example/SwiftAudioPlayer/ViewController.swift index 0985255..eb485ca 100644 --- a/Example/SwiftAudioPlayer/ViewController.swift +++ b/Example/SwiftAudioPlayer/ViewController.swift @@ -6,40 +6,39 @@ // Copyright (c) 2019 tanhakabir. All rights reserved. // -import UIKit -import SwiftAudioPlayer import AVFoundation +import SwiftAudioPlayer +import UIKit class ViewController: UIViewController { - var selectedAudio: AudioInfo = AudioInfo(index: 0) - - var freq:[Int] = [0,0,0,0,0,0,0,0,0,0] - @IBOutlet weak var currentUrlLocationLabel: UILabel! - @IBOutlet weak var bufferProgress: UIProgressView! - @IBOutlet weak var scrubberSlider: UISlider! - - @IBOutlet weak var playPauseButton: UIButton! - @IBOutlet weak var skipBackwardButton: UIButton! - @IBOutlet weak var skipForwardButton: UIButton! - - @IBOutlet weak var audioSelector: UISegmentedControl! - @IBOutlet weak var streamButton: UIButton! - @IBOutlet weak var downloadButton: UIButton! - @IBOutlet weak var rateSlider: UISlider! - - @IBOutlet weak var rateLabel: UILabel! - - @IBOutlet weak var reverbLabel: UILabel! - @IBOutlet weak var reverbSlider: UISlider! - @IBOutlet weak var durationLabel: UILabel! - @IBOutlet weak var currentTimestampLabel: UILabel! - + var selectedAudio: AudioInfo = .init(index: 0) + + var freq: [Int] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + @IBOutlet var currentUrlLocationLabel: UILabel! + @IBOutlet var bufferProgress: UIProgressView! + @IBOutlet var scrubberSlider: UISlider! + + @IBOutlet var playPauseButton: UIButton! + @IBOutlet var skipBackwardButton: UIButton! + @IBOutlet var skipForwardButton: UIButton! + + @IBOutlet var audioSelector: UISegmentedControl! + @IBOutlet var streamButton: UIButton! + @IBOutlet var downloadButton: UIButton! + @IBOutlet var rateSlider: UISlider! + + @IBOutlet var rateLabel: UILabel! + + @IBOutlet var reverbLabel: UILabel! + @IBOutlet var reverbSlider: UISlider! + @IBOutlet var durationLabel: UILabel! + @IBOutlet var currentTimestampLabel: UILabel! + var isDownloading: Bool = false var isStreaming: Bool = false var beingSeeked: Bool = false var loopEnabled = false - - + var downloadId: UInt? var durationId: UInt? var bufferId: UInt? @@ -49,9 +48,9 @@ class ViewController: UIViewController { var duration: Double = 0.0 var playbackStatus: SAPlayingStatus = .paused - + var lastPlayedAudioIndex: Int? - + var isPlayable: Bool = false { didSet { if isPlayable { @@ -65,122 +64,164 @@ class ViewController: UIViewController { } } } - + + let engine = AVAudioEngine() + + let longTrackUrl = URL(string: "https://www.fesliyanstudios.com/musicfiles/2019-04-23_-_Trusted_Advertising_-_www.fesliyanstudios.com/15SecVersion2019-04-23_-_Trusted_Advertising_-_www.fesliyanstudios.com.mp3")! + + let yodelTrackUrl = URL(string: "https://s3-us-west-2.amazonaws.com/s.cdpn.io/123941/Yodel_Sound_Effect.mp3")! + + lazy var player = SAPlayer(engine: engine) + + lazy var player1 = SAPlayer(engine: engine) + + lazy var player2 = SAPlayer(engine: engine) + override func viewDidLoad() { super.viewDidLoad() - + SAPlayer.Downloader.allowUsingCellularData = true - SAPlayer.shared.HTTPHeaderFields = ["User-Agent": "foobar"] - -// SAPlayer.shared.DEBUG_MODE = true - + player.HTTPHeaderFields = ["User-Agent": "foobar"] + + player.DEBUG_MODE = true + player1.DEBUG_MODE = true + player2.DEBUG_MODE = true + isPlayable = false - checkIfAudioDownloaded() + selectAudio(atIndex: 0) - -// addRandomModifiers() - + + addRandomModifiers() + subscribeToChanges() + + checkIfAudioDownloaded() + + // Uncommment the following to test the "play more than one audio at time" +// testMultiAudioRemote() +// testMultiAudioSaved() + } + + func testMultiAudioRemote() { + player1.startRemoteAudio(withRemoteUrl: longTrackUrl) + player2.startRemoteAudio(withRemoteUrl: yodelTrackUrl) + player1.play() + player2.play() } - + + func testMultiAudioSaved() { + SAPlayer.Downloader.downloadAudio(on: player1, withRemoteUrl: longTrackUrl) { longSavedUrl, _ in + + SAPlayer.Downloader.downloadAudio(on: self.player2, withRemoteUrl: self.yodelTrackUrl) { savedUrl, _ in + self.player1.startSavedAudio(withSavedUrl: longSavedUrl) + self.player2.startSavedAudio(withSavedUrl: savedUrl) + + self.player1.play() + self.player2.play() + } + } + } + func addRandomModifiers() { let node = AVAudioUnitReverb() - SAPlayer.shared.audioModifiers.append(node) + player.audioModifiers.append(node) node.wetDryMix = 300 - let frequency:[Int] = [60,170,310,600,1000,3000,6000,12000,14000,16000] - let node2 = AVAudioUnitEQ(numberOfBands:frequency.count) + let frequency: [Int] = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000] + let node2 = AVAudioUnitEQ(numberOfBands: frequency.count) node2.globalGain = 1 - for i in 0...(node2.bands.count-1) { - node2.bands[i].frequency = Float(frequency[i]) - node2.bands[i].gain = 0 - node2.bands[i].bypass = false + for i in 0 ... (node2.bands.count - 1) { + node2.bands[i].frequency = Float(frequency[i]) + node2.bands[i].gain = 0 + node2.bands[i].bypass = false node2.bands[i].filterType = .parametric } - SAPlayer.shared.audioModifiers.append(node2) + player.audioModifiers.append(node2) } override func didReceiveMemoryWarning() { super.didReceiveMemoryWarning() // Dispose of any resources that can be recreated. } - - @IBAction func audioSelected(_ sender: Any) { + + @IBAction func audioSelected(_: Any) { let selected = audioSelector.selectedSegmentIndex - + selectAudio(atIndex: selected) } - + func selectAudio(atIndex i: Int) { selectedAudio.setIndex(i) - + if selectedAudio.savedUrl != nil { downloadButton.isEnabled = true downloadButton.setTitle("Delete downloaded", for: .normal) streamButton.isEnabled = false + player.startSavedAudio(withSavedUrl: selectedAudio.savedUrl!) + isPlayable = true } else { downloadButton.isEnabled = true downloadButton.setTitle("Download", for: .normal) streamButton.isEnabled = true } } - + func checkIfAudioDownloaded() { - for i in 0...2 { + for i in 0 ... 2 { if let savedUrl = SAPlayer.Downloader.getSavedUrl(forRemoteUrl: selectedAudio.getUrl(atIndex: i)) { selectedAudio.addSavedUrl(savedUrl, atIndex: i) } } } - + func subscribeToChanges() { - durationId = SAPlayer.Updates.Duration.subscribe { [weak self] (duration) in + durationId = SAPlayer.Updates.Duration.subscribe { [weak self] duration in guard let self = self else { return } self.durationLabel.text = SAPlayer.prettifyTimestamp(duration) self.duration = duration } - - elapsedId = SAPlayer.Updates.ElapsedTime.subscribe { [weak self] (position) in + + elapsedId = SAPlayer.Updates.ElapsedTime.subscribe { [weak self] position in guard let self = self else { return } - + self.currentTimestampLabel.text = SAPlayer.prettifyTimestamp(position) - + guard self.duration != 0 else { return } - - self.scrubberSlider.value = Float(position/self.duration) + + self.scrubberSlider.value = Float(position / self.duration) } - - downloadId = SAPlayer.Updates.AudioDownloading.subscribe { [weak self] (url, progress) in + + downloadId = SAPlayer.Updates.AudioDownloading.subscribe(on: player) { [weak self] url, progress in guard let self = self else { return } guard url == self.selectedAudio.url else { return } - + if self.isDownloading { DispatchQueue.main.async { UIView.performWithoutAnimation { - self.downloadButton.setTitle("Cancel \(String(format: "%.2f", (progress * 100)))%", for: .normal) + self.downloadButton.setTitle("Cancel \(String(format: "%.2f", progress * 100))%", for: .normal) } } } } - - bufferId = SAPlayer.Updates.StreamingBuffer.subscribe{ [weak self] (buffer) in + + bufferId = SAPlayer.Updates.StreamingBuffer.subscribe { [weak self] buffer in guard let self = self else { return } - + self.bufferProgress.progress = Float(buffer.bufferingProgress) - + if buffer.bufferingProgress >= 0.99 { self.streamButton.isEnabled = false } else { self.streamButton.isEnabled = true } - + self.isPlayable = buffer.isReadyForPlaying } - - playingStatusId = SAPlayer.Updates.PlayingStatus.subscribe { [weak self] (playing) in + + playingStatusId = SAPlayer.Updates.PlayingStatus.subscribe { [weak self] playing in guard let self = self else { return } - + self.playbackStatus = playing - + switch playing { case .playing: self.isPlayable = true @@ -202,26 +243,26 @@ class ViewController: UIViewController { return } } - + queueId = SAPlayer.Updates.AudioQueue.subscribe { [weak self] forthcomingPlaybackUrl in guard let self = self else { return } /// we update the selected audio. this is a little contrived, but allows us to update outlets if let indexFound = self.selectedAudio.getIndex(forURL: forthcomingPlaybackUrl) { self.selectAudio(atIndex: indexFound) } - + self.currentUrlLocationLabel.text = "\(forthcomingPlaybackUrl.absoluteString)" } } - + func unsubscribeFromChanges() { - guard let durationId = self.durationId, - let elapsedId = self.elapsedId, - let downloadId = self.downloadId, - let queueId = self.queueId, - let bufferId = self.bufferId, - let playingStatusId = self.playingStatusId else { return } - + guard let durationId = durationId, + let elapsedId = elapsedId, + let downloadId = downloadId, + let queueId = queueId, + let bufferId = bufferId, + let playingStatusId = playingStatusId else { return } + SAPlayer.Updates.Duration.unsubscribe(durationId) SAPlayer.Updates.ElapsedTime.unsubscribe(elapsedId) SAPlayer.Updates.AudioDownloading.unsubscribe(downloadId) @@ -229,47 +270,47 @@ class ViewController: UIViewController { SAPlayer.Updates.StreamingBuffer.unsubscribe(bufferId) SAPlayer.Updates.PlayingStatus.unsubscribe(playingStatusId) } - - - @IBAction func scrubberStartedSeeking(_ sender: UISlider) { + + @IBAction func scrubberStartedSeeking(_: UISlider) { beingSeeked = true } - - @IBAction func scrubberSeeked(_ sender: Any) { + + @IBAction func scrubberSeeked(_: Any) { let value = Double(scrubberSlider.value) * duration - SAPlayer.shared.seekTo(seconds: value) + player.seekTo(seconds: value) beingSeeked = false } - - - @IBAction func rateChanged(_ sender: Any) { + + @IBAction func rateChanged(_: Any) { let speed = rateSlider.value rateLabel.text = "rate: \(speed)x" - + if skipSilencesSwitch.isOn { - SAPlayer.Features.SkipSilences.setRateSafely(speed) // if using Skip Silences, we need use this version of setting rate to safely change the rate with the feature enabled. + SAPlayer.Features.SkipSilences.setRateSafely(speed, on: player) // if using Skip Silences, we need use this version of setting rate to safely change the rate with the feature enabled. } else { - SAPlayer.shared.rate = speed + player.rate = speed } } - @IBAction func reverbChanged(_ sender: Any) { + + @IBAction func reverbChanged(_: Any) { let reverb = reverbSlider.value reverbLabel.text = "reverb: \(reverb)" - if let node = SAPlayer.shared.audioModifiers[1] as? AVAudioUnitReverb { + if let node = player.audioModifiers[1] as? AVAudioUnitReverb { node.wetDryMix = reverb } } - @IBAction func queueTouched(_ sender: Any) { + + @IBAction func queueTouched(_: Any) { if let savedUrl = selectedAudio.savedUrl { - SAPlayer.shared.queueSavedAudio(withSavedUrl: savedUrl) + player.queueSavedAudio(withSavedUrl: savedUrl) } else { - SAPlayer.shared.queueRemoteAudio(withRemoteUrl: selectedAudio.url) + player.queueRemoteAudio(withRemoteUrl: selectedAudio.url) } - - print("queue: \(SAPlayer.shared.audioQueued)") + + print("queue: \(player.audioQueued)") } - - @IBAction func downloadTouched(_ sender: Any) { + + @IBAction func downloadTouched(_: Any) { if !isDownloading { if let savedUrl = SAPlayer.Downloader.getSavedUrl(forRemoteUrl: selectedAudio.url) { SAPlayer.Downloader.deleteDownloaded(withSavedUrl: savedUrl) @@ -280,7 +321,7 @@ class ViewController: UIViewController { } else { downloadButton.setTitle("Cancel 0%", for: .normal) isDownloading = true - SAPlayer.Downloader.downloadAudio(withRemoteUrl: selectedAudio.url, completion: { [weak self] (url, error) in + SAPlayer.Downloader.downloadAudio(on: player, withRemoteUrl: selectedAudio.url, completion: { [weak self] url, error in guard let self = self else { return } guard error == nil else { DispatchQueue.main.async { @@ -288,10 +329,11 @@ class ViewController: UIViewController { } return } - + DispatchQueue.main.async { self.currentUrlLocationLabel.text = "saved to: \(url.lastPathComponent)" self.selectedAudio.addSavedUrl(url) + self.selectAudio(atIndex: self.selectedAudio.index) } }) streamButton.isEnabled = false @@ -303,14 +345,14 @@ class ViewController: UIViewController { isDownloading = false } } - - @IBAction func streamTouched(_ sender: Any) { + + @IBAction func streamTouched(_: Any) { if !isStreaming { - self.currentUrlLocationLabel.text = "remote url: \(selectedAudio.url.absoluteString)" + currentUrlLocationLabel.text = "remote url: \(selectedAudio.url.absoluteString)" if selectedAudio.index == 2 { // radio - SAPlayer.shared.startRemoteAudio(withRemoteUrl: selectedAudio.url, bitrate: .low, mediaInfo: selectedAudio.lockscreenInfo) + player.startRemoteAudio(withRemoteUrl: selectedAudio.url, bitrate: .low, mediaInfo: selectedAudio.lockscreenInfo) } else { - SAPlayer.shared.startRemoteAudio(withRemoteUrl: selectedAudio.url, mediaInfo: selectedAudio.lockscreenInfo) + player.startRemoteAudio(withRemoteUrl: selectedAudio.url, mediaInfo: selectedAudio.lockscreenInfo) } lastPlayedAudioIndex = selectedAudio.index @@ -318,68 +360,67 @@ class ViewController: UIViewController { downloadButton.isEnabled = false isStreaming = true } else { - SAPlayer.shared.stopStreamingRemoteAudio() + player.stopStreamingRemoteAudio() streamButton.setTitle("Stream", for: .normal) downloadButton.isEnabled = true isStreaming = false } } - - @IBAction func playPauseTouched(_ sender: Any) { - SAPlayer.shared.togglePlayAndPause() + + @IBAction func playPauseTouched(_: Any) { + player.togglePlayAndPause() } - - @IBAction func skipBackwardTouched(_ sender: Any) { - SAPlayer.shared.skipBackwards() + + @IBAction func skipBackwardTouched(_: Any) { + player.skipBackwards() } - - @IBAction func skipForwardTouched(_ sender: Any) { - SAPlayer.shared.skipForward() + + @IBAction func skipForwardTouched(_: Any) { + player.skipForward() } + @IBAction func setEqualizerValue(_ sender: Any) { - if let slider = sender as? UISlider{ + if let slider = sender as? UISlider { print("slider of index:", slider.tag, "is changed to", slider.value) freq[slider.tag] = Int(slider.value) - print("current frequency : ",freq) - if let node = SAPlayer.shared.audioModifiers[2] as? AVAudioUnitEQ{ - for i in 0...(node.bands.count - 1){ + print("current frequency : ", freq) + if let node = player.audioModifiers[2] as? AVAudioUnitEQ { + for i in 0 ... (node.bands.count - 1) { node.bands[i].gain = Float(freq[i]) } } } - } - - @IBOutlet weak var skipSilencesSwitch: UISwitch! - - @IBAction func skipSilencesSwitched(_ sender: Any) { + + @IBOutlet var skipSilencesSwitch: UISwitch! + + @IBAction func skipSilencesSwitched(_: Any) { if skipSilencesSwitch.isOn { - _ = SAPlayer.Features.SkipSilences.enable() + _ = SAPlayer.Features.SkipSilences.enable(on: player) } else { - _ = SAPlayer.Features.SkipSilences.disable() + _ = SAPlayer.Features.SkipSilences.disable(on: player) } } - @IBOutlet weak var sleepSwitch: UISwitch! - - @IBAction func sleepSwitched(_ sender: Any) { + + @IBOutlet var sleepSwitch: UISwitch! + + @IBAction func sleepSwitched(_: Any) { if sleepSwitch.isOn { - _ = SAPlayer.Features.SleepTimer.enable(afterDelay: 5.0) + _ = SAPlayer.Features.SleepTimer.enable(afterDelay: 5.0, on: player) } else { _ = SAPlayer.Features.SleepTimer.disable() } } - - @IBOutlet weak var loopSwitch: UISwitch! - - @IBAction func loopSwitched(_ sender: Any) { + + @IBOutlet var loopSwitch: UISwitch! + + @IBAction func loopSwitched(_: Any) { loopEnabled = loopSwitch.isOn - + if loopSwitch.isOn { - SAPlayer.Features.Loop.enable() + SAPlayer.Features.Loop.enable(on: player) } else { SAPlayer.Features.Loop.disable() } - } } - diff --git a/Example/Tests/Tests.swift b/Example/Tests/Tests.swift index 827a84e..841882c 100644 --- a/Example/Tests/Tests.swift +++ b/Example/Tests/Tests.swift @@ -1,28 +1,26 @@ -import XCTest import SwiftAudioPlayer +import XCTest class Tests: XCTestCase { - override func setUp() { super.setUp() // Put setup code here. This method is called before the invocation of each test method in the class. } - + override func tearDown() { // Put teardown code here. This method is called after the invocation of each test method in the class. super.tearDown() } - + func testExample() { // This is an example of a functional test case. XCTAssert(true, "Pass") } - + func testPerformanceExample() { // This is an example of a performance test case. - self.measure() { + measure { // Put the code you want to measure the time of here. } } - } diff --git a/Package.swift b/Package.swift index 4d7f72d..9db95df 100644 --- a/Package.swift +++ b/Package.swift @@ -6,13 +6,14 @@ import PackageDescription let package = Package( name: "SwiftAudioPlayer", platforms: [ - .iOS(.v10), .tvOS(.v10) + .iOS(.v10), .tvOS(.v10), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. .library( name: "SwiftAudioPlayer", - targets: ["SwiftAudioPlayer"]) + targets: ["SwiftAudioPlayer"] + ), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -24,7 +25,7 @@ let package = Package( .target( name: "SwiftAudioPlayer", path: "Source" - ) + ), ], swiftLanguageVersions: [.v5] ) diff --git a/Source/Directors/AudioClockDirector.swift b/Source/Directors/AudioClockDirector.swift index 03cb657..46d7e19 100644 --- a/Source/Directors/AudioClockDirector.swift +++ b/Source/Directors/AudioClockDirector.swift @@ -23,110 +23,107 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import CoreMedia +import Foundation class AudioClockDirector { static let shared = AudioClockDirector() private var currentAudioKey: Key? - + private var depNeedleClosures: DirectorThreadSafeClosuresDeprecated = DirectorThreadSafeClosuresDeprecated() private var depDurationClosures: DirectorThreadSafeClosuresDeprecated = DirectorThreadSafeClosuresDeprecated() private var depPlayingStatusClosures: DirectorThreadSafeClosuresDeprecated = DirectorThreadSafeClosuresDeprecated() private var depBufferClosures: DirectorThreadSafeClosuresDeprecated = DirectorThreadSafeClosuresDeprecated() - + private var needleClosures: DirectorThreadSafeClosures = DirectorThreadSafeClosures() private var durationClosures: DirectorThreadSafeClosures = DirectorThreadSafeClosures() private var playingStatusClosures: DirectorThreadSafeClosures = DirectorThreadSafeClosures() private var bufferClosures: DirectorThreadSafeClosures = DirectorThreadSafeClosures() - + private init() {} - + func setKey(_ key: Key) { currentAudioKey = key } - + func resetCache() { needleClosures.resetCache() durationClosures.resetCache() playingStatusClosures.resetCache() bufferClosures.resetCache() } - + func clear() { depNeedleClosures.clear() depDurationClosures.clear() depPlayingStatusClosures.clear() depBufferClosures.clear() - + needleClosures.clear() durationClosures.clear() playingStatusClosures.clear() bufferClosures.clear() } - + // MARK: - Attaches - + // Needle @available(*, deprecated, message: "Use subscribe without key in the closure for current audio updates") func attachToChangesInNeedle(closure: @escaping (Key, Needle) throws -> Void) -> UInt { return depNeedleClosures.attach(closure: closure) } - + func attachToChangesInNeedle(closure: @escaping (Needle) throws -> Void) -> UInt { return needleClosures.attach(closure: closure) } - - + // Duration @available(*, deprecated, message: "Use subscribe without key in the closure for current audio updates") func attachToChangesInDuration(closure: @escaping (Key, Duration) throws -> Void) -> UInt { return depDurationClosures.attach(closure: closure) } - + func attachToChangesInDuration(closure: @escaping (Duration) throws -> Void) -> UInt { return durationClosures.attach(closure: closure) } - - + // Playing status @available(*, deprecated, message: "Use subscribe without key in the closure for current audio updates") - func attachToChangesInPlayingStatus(closure: @escaping (Key, SAPlayingStatus) throws -> Void) -> UInt{ + func attachToChangesInPlayingStatus(closure: @escaping (Key, SAPlayingStatus) throws -> Void) -> UInt { return depPlayingStatusClosures.attach(closure: closure) } - - func attachToChangesInPlayingStatus(closure: @escaping (SAPlayingStatus) throws -> Void) -> UInt{ + + func attachToChangesInPlayingStatus(closure: @escaping (SAPlayingStatus) throws -> Void) -> UInt { return playingStatusClosures.attach(closure: closure) } - - + // Buffer @available(*, deprecated, message: "Use subscribe without key in the closure for current audio updates") - func attachToChangesInBufferedRange(closure: @escaping (Key, SAAudioAvailabilityRange) throws -> Void) -> UInt{ + func attachToChangesInBufferedRange(closure: @escaping (Key, SAAudioAvailabilityRange) throws -> Void) -> UInt { return depBufferClosures.attach(closure: closure) } - - func attachToChangesInBufferedRange(closure: @escaping (SAAudioAvailabilityRange) throws -> Void) -> UInt{ + + func attachToChangesInBufferedRange(closure: @escaping (SAAudioAvailabilityRange) throws -> Void) -> UInt { return bufferClosures.attach(closure: closure) } - - + // MARK: - Detaches + func detachFromChangesInNeedle(withID id: UInt) { depNeedleClosures.detach(id: id) needleClosures.detach(id: id) } - + func detachFromChangesInDuration(withID id: UInt) { depDurationClosures.detach(id: id) durationClosures.detach(id: id) } - + func detachFromChangesInPlayingStatus(withID id: UInt) { depPlayingStatusClosures.detach(id: id) playingStatusClosures.detach(id: id) } - + func detachFromChangesInBufferedRange(withID id: UInt) { depBufferClosures.detach(id: id) bufferClosures.detach(id: id) @@ -134,6 +131,7 @@ class AudioClockDirector { } // MARK: - Receives notifications from AudioEngine on ticks + extension AudioClockDirector { func needleTick(_ key: Key, needle: Needle) { guard key == currentAudioKey else { diff --git a/Source/Directors/AudioQueueDirector.swift b/Source/Directors/AudioQueueDirector.swift index a60b1b9..613bd78 100644 --- a/Source/Directors/AudioQueueDirector.swift +++ b/Source/Directors/AudioQueueDirector.swift @@ -25,7 +25,7 @@ class AudioQueueDirector { func detach(withID id: UInt) { closures.detach(id: id) } - + func changeInQueue(url: URL) { closures.broadcast(payload: url) } diff --git a/Source/Directors/DownloadProgressDirector.swift b/Source/Directors/DownloadProgressDirector.swift index 4b28784..e9bf1b1 100644 --- a/Source/Directors/DownloadProgressDirector.swift +++ b/Source/Directors/DownloadProgressDirector.swift @@ -27,25 +27,25 @@ import Foundation class DownloadProgressDirector { static let shared = DownloadProgressDirector() - + var closures: DirectorThreadSafeClosuresDeprecated = DirectorThreadSafeClosuresDeprecated() - + private init() { - AudioDataManager.shared.attach { [weak self] (key, progress) in + AudioDataManager.shared.attach { [weak self] key, progress in self?.closures.broadcast(key: key, payload: progress) } } - + func create() {} - + func clear() { closures.clear() } - + func attach(closure: @escaping (Key, Double) throws -> Void) -> UInt { return closures.attach(closure: closure) } - + func detach(withID id: UInt) { closures.detach(id: id) } diff --git a/Source/Directors/StreamingDownloadDirector.swift b/Source/Directors/StreamingDownloadDirector.swift index 3ab9b0d..89a58e4 100644 --- a/Source/Directors/StreamingDownloadDirector.swift +++ b/Source/Directors/StreamingDownloadDirector.swift @@ -27,27 +27,27 @@ import Foundation class StreamingDownloadDirector { static let shared = StreamingDownloadDirector() private var currentAudioKey: Key? - + var closures: DirectorThreadSafeClosures = DirectorThreadSafeClosures() - + private init() {} - + func setKey(_ key: Key) { currentAudioKey = key } - + func resetCache() { closures.resetCache() } - + func clear() { closures.clear() } - + func attach(closure: @escaping (Double) throws -> Void) -> UInt { return closures.attach(closure: closure) } - + func detach(withID id: UInt) { closures.detach(id: id) } @@ -59,7 +59,7 @@ extension StreamingDownloadDirector { Log.debug("silence old updates") return } - + closures.broadcast(payload: networkStreamProgress) } } diff --git a/Source/Engine/AudioDiskEngine.swift b/Source/Engine/AudioDiskEngine.swift index 77b1b29..69596e2 100644 --- a/Source/Engine/AudioDiskEngine.swift +++ b/Source/Engine/AudioDiskEngine.swift @@ -23,8 +23,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation class AudioDiskEngine: AudioEngine { var audioFormat: AVAudioFormat? @@ -32,31 +32,32 @@ class AudioDiskEngine: AudioEngine { var audioLengthSamples: AVAudioFramePosition = 0 var seekFrame: AVAudioFramePosition = 0 var currentPosition: AVAudioFramePosition = 0 - + var audioFile: AVAudioFile? - + var currentFrame: AVAudioFramePosition { guard let lastRenderTime = playerNode.lastRenderTime, - let playerTime = playerNode.playerTime(forNodeTime: lastRenderTime) else { - return 0 + let playerTime = playerNode.playerTime(forNodeTime: lastRenderTime) + else { + return 0 } - + return playerTime.sampleTime } - + var audioLengthSeconds: Float = 0 - - init(withSavedUrl url: AudioURL, delegate:AudioEngineDelegate?) { + + init(withSavedUrl url: AudioURL, delegate: AudioEngineDelegate?, engine: AVAudioEngine) { Log.info(url.key) - + do { audioFile = try AVAudioFile(forReading: url) } catch { Log.monitor(error.localizedDescription) } - - super.init(url: url, delegate: delegate, engineAudioFormat: audioFile?.processingFormat ?? AudioEngine.defaultEngineAudioFormat) - + + super.init(url: url, delegate: delegate, engineAudioFormat: audioFile?.processingFormat ?? AudioEngine.defaultEngineAudioFormat, engine: engine) + if let file = audioFile { Log.debug("Audio file exists") audioLengthSamples = file.length @@ -68,30 +69,30 @@ class AudioDiskEngine: AudioEngine { } else { Log.monitor("Could not load downloaded file with url: \(url)") } - + doRepeatedly(timeInterval: 0.2) { [weak self] in guard let self = self else { return } - + self.updateIsPlaying() self.updateNeedle() } - + scheduleAudioFile() } - + private func scheduleAudioFile() { guard let audioFile = audioFile else { return } - + playerNode.scheduleFile(audioFile, at: nil, completionHandler: nil) } - + private func updateNeedle() { guard engine.isRunning else { return } - + currentPosition = currentFrame + seekFrame currentPosition = max(currentPosition, 0) currentPosition = min(currentPosition, audioLengthSamples) - + if currentPosition >= audioLengthSamples { playerNode.stop() if state == .resumed { @@ -99,44 +100,44 @@ class AudioDiskEngine: AudioEngine { } playingStatus = .ended } - + guard audioSampleRate != 0 else { Log.error("Missing audio sample rate in update needle timer function!") return } - - needle = Double(Float(currentPosition)/audioSampleRate) + + needle = Double(Float(currentPosition) / audioSampleRate) } - + override func seek(toNeedle needle: Needle) { guard let audioFile = audioFile else { Log.error("did not have audio file when trying to seek") return } - + let playing = playerNode.isPlaying let seekToNeedle = needle > Needle(duration) ? Needle(duration) : needle - + self.needle = seekToNeedle // to tick while paused - + seekFrame = AVAudioFramePosition(Float(seekToNeedle) * audioSampleRate) seekFrame = max(seekFrame, 0) seekFrame = min(seekFrame, audioLengthSamples) currentPosition = seekFrame - + playerNode.stop() - + if currentPosition < audioLengthSamples { playerNode.scheduleSegment(audioFile, startingFrame: seekFrame, frameCount: AVAudioFrameCount(audioLengthSamples - seekFrame), at: nil, completionHandler: nil) - + if playing { playerNode.play() } } } - + override func invalidate() { super.invalidate() - //Nothing to invalidate for disk + // Nothing to invalidate for disk } } diff --git a/Source/Engine/AudioEngine.swift b/Source/Engine/AudioEngine.swift index ab20d98..a28b3cd 100644 --- a/Source/Engine/AudioEngine.swift +++ b/Source/Engine/AudioEngine.swift @@ -23,8 +23,8 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation protocol AudioEngineProtocol { var key: Key { get } @@ -37,24 +37,25 @@ protocol AudioEngineProtocol { protocol AudioEngineDelegate: AnyObject { func didError() + var audioModifiers: [AVAudioUnit] { get } } class AudioEngine: AudioEngineProtocol { - weak var delegate:AudioEngineDelegate? - var key:Key - + weak var delegate: AudioEngineDelegate? + var key: Key + var engine: AVAudioEngine! var playerNode: AVAudioPlayerNode! private var engineInvalidated: Bool = false - - static let defaultEngineAudioFormat: AVAudioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false)! - - var state:TimerState = .suspended + + static let defaultEngineAudioFormat: AVAudioFormat = .init(commonFormat: .pcmFormatFloat32, sampleRate: 44100, channels: 2, interleaved: false)! + + var state: TimerState = .suspended enum TimerState { case suspended case resumed } - + var needle: Needle = -1 { didSet { if needle >= 0 && oldValue != needle { @@ -62,7 +63,7 @@ class AudioEngine: AudioEngineProtocol { } } } - + var duration: Duration = -1 { didSet { if duration >= 0 && oldValue != duration { @@ -70,55 +71,55 @@ class AudioEngine: AudioEngineProtocol { } } } - - var playingStatus: SAPlayingStatus? = nil { + + var playingStatus: SAPlayingStatus? { didSet { guard playingStatus != oldValue, let status = playingStatus else { return } - + AudioClockDirector.shared.audioPlayingStatusWasChanged(key, status: status) } } - - var bufferedSecondsDebouncer: SAAudioAvailabilityRange = SAAudioAvailabilityRange(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, predictedDurationToLoad: Double.greatestFiniteMagnitude, isPlayable: false) - var bufferedSeconds: SAAudioAvailabilityRange = SAAudioAvailabilityRange(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, predictedDurationToLoad: Double.greatestFiniteMagnitude, isPlayable: false) { + + var bufferedSecondsDebouncer: SAAudioAvailabilityRange = .init(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, predictedDurationToLoad: Double.greatestFiniteMagnitude, isPlayable: false) + var bufferedSeconds: SAAudioAvailabilityRange = .init(startingNeedle: 0.0, durationLoadedByNetwork: 0.0, predictedDurationToLoad: Double.greatestFiniteMagnitude, isPlayable: false) { didSet { - if bufferedSeconds.startingNeedle == 0.0 && bufferedSeconds.durationLoadedByNetwork == 0.0 { + if bufferedSeconds.startingNeedle == 0.0, bufferedSeconds.durationLoadedByNetwork == 0.0 { bufferedSecondsDebouncer = bufferedSeconds AudioClockDirector.shared.changeInAudioBuffered(key, buffered: bufferedSeconds) return } - - if bufferedSeconds.startingNeedle == oldValue.startingNeedle && bufferedSeconds.durationLoadedByNetwork == oldValue.durationLoadedByNetwork { + + if bufferedSeconds.startingNeedle == oldValue.startingNeedle, bufferedSeconds.durationLoadedByNetwork == oldValue.durationLoadedByNetwork { return } - + if bufferedSeconds.durationLoadedByNetwork - DEBOUNCING_BUFFER_TIME < bufferedSecondsDebouncer.durationLoadedByNetwork { Log.debug("skipping pushing buffer: \(bufferedSeconds)") return } - + bufferedSecondsDebouncer = bufferedSeconds AudioClockDirector.shared.changeInAudioBuffered(key, buffered: bufferedSeconds) } } private var audioModifiers: [AVAudioUnit]? - - init(url: AudioURL, delegate:AudioEngineDelegate?, engineAudioFormat: AVAudioFormat) { - self.key = url.key + + init(url: AudioURL, delegate: AudioEngineDelegate?, engineAudioFormat: AVAudioFormat, engine: AVAudioEngine) { + key = url.key self.delegate = delegate - - engine = AVAudioEngine() + + self.engine = engine playerNode = AVAudioPlayerNode() - + initHelper(engineAudioFormat) } - + func initHelper(_ engineAudioFormat: AVAudioFormat) { engine.attach(playerNode) - audioModifiers = SAPlayer.shared.audioModifiers + audioModifiers = delegate?.audioModifiers defer { engine.prepare() } @@ -126,9 +127,9 @@ class AudioEngine: AudioEngineProtocol { engine.connect(playerNode, to: engine.mainMixerNode, format: engineAudioFormat) return } - + audioModifiers.forEach { engine.attach($0) } - + var i = 0 let node = audioModifiers[i] @@ -148,33 +149,31 @@ class AudioEngine: AudioEngineProtocol { engine.connect(finalNode, to: engine.mainMixerNode, format: engineAudioFormat) } - + deinit { if state == .resumed { playerNode.stop() - engine.stop() } - + engine.disconnectNodeInput(self.playerNode) engine.detach(self.playerNode) - - engine = nil + playerNode = nil Log.info("deinit AVAudioEngine for \(key)") } - - func doRepeatedly(timeInterval: Double, _ closure: @escaping () -> ()) { + + func doRepeatedly(timeInterval: Double, _ closure: @escaping () -> Void) { // A common error in AVAudioEngine is 'required condition is false: nil == owningEngine || GetEngine() == owningEngine' // where there can only be one instance of engine running at a time and if there is already one when trying to start // a new one then this error will be thrown. - + // To handle this error we need to make sure we properly dispose of the engine when done using. In the case of timers, a // repeating timer will maintain a strong reference to the body even if you state that you wanted a weak reference to self // to mitigate this for repeating timers, you can either call timer.invalidate() properly or don't use repeat block timers. // To be in better control of references and to mitigate any unforeseen issues, I decided to implement a recurisive version // of the repeat block timer so I'm in full control of when to invalidate. - - Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { [weak self] (timer: Timer) in + + Timer.scheduledTimer(withTimeInterval: timeInterval, repeats: false) { [weak self] (_: Timer) in guard let self = self else { return } guard !self.engineInvalidated else { self.delegate = nil @@ -184,7 +183,7 @@ class AudioEngine: AudioEngineProtocol { self.doRepeatedly(timeInterval: timeInterval, closure) } } - + func updateIsPlaying() { if !bufferedSeconds.isPlayable { if bufferedSeconds.reachedEndOfAudio(needle: needle) { @@ -194,47 +193,45 @@ class AudioEngine: AudioEngineProtocol { } return } - + let isPlaying = engine.isRunning && playerNode.isPlaying playingStatus = isPlaying ? .playing : .paused } - + func play() { // https://stackoverflow.com/questions/36754934/update-mpremotecommandcenter-play-pause-button if !(engine.isRunning) { do { try engine.start() - - } catch let error { + + } catch { Log.monitor(error.localizedDescription) } } - + playerNode.play() - + if state == .suspended { state = .resumed } } - + func pause() { // https://stackoverflow.com/questions/36754934/update-mpremotecommandcenter-play-pause-button playerNode.pause() - engine.pause() - + if state == .resumed { state = .suspended } } - - func seek(toNeedle needle: Needle) { + + func seek(toNeedle _: Needle) { fatalError("No implementation for seek inAudioEngine, should be using streaming or disk type") } - + func invalidate() { engineInvalidated = true playerNode.stop() - engine.stop() if let audioModifiers = audioModifiers, audioModifiers.count > 0 { audioModifiers.forEach { engine.detach($0) } diff --git a/Source/Engine/AudioStreamEngine.swift b/Source/Engine/AudioStreamEngine.swift index b3fb310..4e6d7bf 100644 --- a/Source/Engine/AudioStreamEngine.swift +++ b/Source/Engine/AudioStreamEngine.swift @@ -29,13 +29,13 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation /** Start of the streaming chain. Get PCM buffer from lower chain and feed it to engine - + Main responsibilities: POLL FOR BUFFER. When we start a stream it takes time for the lower chain to receive audio format. We don't know how long this would take. Therefore we poll @@ -43,59 +43,60 @@ import AVFoundation seeked beyond pcm buffer, and down-chain buffer. We keep polling until we fill N buffers. If we stick to one buffer the audio sounds choppy because sometimes the parser takes longer than usual to parse a buffer - + RECURSE FOR BUFFER. When we receive N buffers we switch to recursive mode. This means we only ask for the next buffer when one of the loaded buffers are used up. This is to prevent high CPU usage (100%) because otherwise we keep polling and parser keeps parsing even though the user is nowhere near that part of audio - + UPDATES FOR UI. Duration, needle ticking, playing status, etc. - + HANDLE PLAYING. Ensure the engine is in the correct state when playing, pausing, or seeking */ class AudioStreamEngine: AudioEngine { - //Constants - private let MAX_POLL_BUFFER_COUNT = 300 //Having one buffer in engine at a time is choppy. + // Constants + private let MAX_POLL_BUFFER_COUNT = 300 // Having one buffer in engine at a time is choppy. private let MIN_BUFFERS_TO_BE_PLAYABLE = 1 private var PCM_BUFFER_SIZE: AVAudioFrameCount = 8192 - + private let queue = DispatchQueue(label: "SwiftAudioPlayer.StreamEngine", qos: .userInitiated) - - //From init + + // From init private var converter: AudioConvertable! - - //Fields + + // Fields private var currentTimeOffset: TimeInterval = 0 private var streamChangeListenerId: UInt? - + private var numberOfBuffersScheduledInTotal = 0 { didSet { Log.debug("number of buffers scheduled in total: \(numberOfBuffersScheduledInTotal)") if numberOfBuffersScheduledInTotal == 0 { if playingStatus == .playing { wasPlaying = true } - pause() + // Pausing here triggers an odd state where, while downloading the audio the player will not resume playing when the first buffer is ready +// pause() // delegate?.didError() // TODO: we should not have an error here. We should instead have the throttler // propegate when it doesn't enough buffers while they were playing // TODO: "Make this a legitimate warning to user about needing more data from stream" } - - if numberOfBuffersScheduledInTotal > MIN_BUFFERS_TO_BE_PLAYABLE && wasPlaying { + + if numberOfBuffersScheduledInTotal > MIN_BUFFERS_TO_BE_PLAYABLE, wasPlaying { wasPlaying = false play() } } } - + private var wasPlaying = false private var numberOfBuffersScheduledFromPoll = 0 { didSet { if numberOfBuffersScheduledFromPoll > MAX_POLL_BUFFER_COUNT { shouldPollForNextBuffer = false } - + if numberOfBuffersScheduledFromPoll > MIN_BUFFERS_TO_BE_PLAYABLE { if wasPlaying { play() @@ -104,7 +105,7 @@ class AudioStreamEngine: AudioEngine { } } } - + private var shouldPollForNextBuffer = true { didSet { if shouldPollForNextBuffer { @@ -112,107 +113,106 @@ class AudioStreamEngine: AudioEngine { } } } - - //Prediction keeps fluctuating. We debounce to keep the UI from jitter + + // Prediction keeps fluctuating. We debounce to keep the UI from jitter private var predictedStreamDurationDebounceHelper: Duration = 0 private var predictedStreamDuration: Duration = 0 { didSet { let d = predictedStreamDuration let s = predictedStreamDurationDebounceHelper - if d/DEBOUNCING_BUFFER_TIME != s/DEBOUNCING_BUFFER_TIME { + if d / DEBOUNCING_BUFFER_TIME != s / DEBOUNCING_BUFFER_TIME { predictedStreamDurationDebounceHelper = predictedStreamDuration duration = predictedStreamDuration } } } - + private var seekNeedleCommandBeforeEngineWasReady: Needle? private var isPlayable = false { didSet { if isPlayable != oldValue { Log.info("isPlayable status changed: \(isPlayable)") } - + if isPlayable, let needle = seekNeedleCommandBeforeEngineWasReady { seekNeedleCommandBeforeEngineWasReady = nil seek(toNeedle: needle) } } } - - init(withRemoteUrl url: AudioURL, delegate:AudioEngineDelegate?, bitrate: SAPlayerBitrate) { + + init(withRemoteUrl url: AudioURL, delegate: AudioEngineDelegate?, bitrate: SAPlayerBitrate, engine: AVAudioEngine) { Log.info(url) - super.init(url: url, delegate: delegate, engineAudioFormat: AudioEngine.defaultEngineAudioFormat) - + super.init(url: url, delegate: delegate, engineAudioFormat: AudioEngine.defaultEngineAudioFormat, engine: engine) + switch bitrate { case .high: PCM_BUFFER_SIZE = 8192 case .low: PCM_BUFFER_SIZE = 4096 } - + do { converter = try AudioConverter(withRemoteUrl: url, toEngineAudioFormat: AudioEngine.defaultEngineAudioFormat, withPCMBufferSize: PCM_BUFFER_SIZE) } catch { delegate?.didError() } - + StreamingDownloadDirector.shared.setKey(key) StreamingDownloadDirector.shared.resetCache() - - streamChangeListenerId = StreamingDownloadDirector.shared.attach { [weak self] (progress) in + + streamChangeListenerId = StreamingDownloadDirector.shared.attach { [weak self] _ in guard let self = self else { return } // polling for buffers when we receive data. This won't be throttled on fresh new audio or seeked audio but in all other cases it most likely will be throttled self.pollForNextBuffer() // no buffer updates because thread issues if I try to update buffer status in streaming listener } - - + let timeInterval = 1 / (converter.engineAudioFormat.sampleRate / Double(PCM_BUFFER_SIZE)) - + doRepeatedly(timeInterval: timeInterval) { [weak self] in guard let self = self else { return } - + self.repeatedUpdates() } } - + deinit { if let id = streamChangeListenerId { StreamingDownloadDirector.shared.detach(withID: id) } } - + private func repeatedUpdates() { - self.pollForNextBuffer() - self.updateNetworkBufferRange() // thread issues if I try to update buffer status in streaming listener - self.updateNeedle() - self.updateIsPlaying() - self.updateDuration() + pollForNextBuffer() + updateNetworkBufferRange() // thread issues if I try to update buffer status in streaming listener + updateNeedle() + updateIsPlaying() + updateDuration() } - - //MARK:- Timer loop - - //Called when - //1. First time audio is finally parsed - //2. When we run to the end of the network buffer and we're waiting again + + // MARK: - Timer loop + + // Called when + // 1. First time audio is finally parsed + // 2. When we run to the end of the network buffer and we're waiting again private func pollForNextBuffer() { guard shouldPollForNextBuffer else { return } - + pollForNextBufferRecursive() } - + private func pollForNextBufferRecursive() { do { var nextScheduledBuffer: AVAudioPCMBuffer! = try converter.pullBuffer() numberOfBuffersScheduledFromPoll += 1 numberOfBuffersScheduledInTotal += 1 - + Log.debug("processed buffer for engine of frame length \(nextScheduledBuffer.frameLength)") queue.async { [weak self] in if #available(iOS 11.0, tvOS 11.0, *) { // to make sure the pcm buffers are properly free'd from memory we need to nil them after the player has used them - self?.playerNode.scheduleBuffer(nextScheduledBuffer, completionCallbackType: .dataConsumed, completionHandler: { (_) in + self?.playerNode.scheduleBuffer(nextScheduledBuffer, completionCallbackType: .dataConsumed, completionHandler: { _ in nextScheduledBuffer = nil self?.numberOfBuffersScheduledInTotal -= 1 self?.pollForNextBufferRecursive() @@ -225,8 +225,8 @@ class AudioStreamEngine: AudioEngine { } } } - - //TODO: re-do how to pass and log these errors + + // TODO: re-do how to pass and log these errors } catch ConverterError.reachedEndOfFile { Log.info(ConverterError.reachedEndOfFile.localizedDescription) } catch ConverterError.notEnoughData { @@ -237,40 +237,41 @@ class AudioStreamEngine: AudioEngine { Log.debug(error.localizedDescription) } } - - private func updateNetworkBufferRange() { //for ui + + private func updateNetworkBufferRange() { // for ui let range = converter.pollNetworkAudioAvailabilityRange() isPlayable = (numberOfBuffersScheduledInTotal >= MIN_BUFFERS_TO_BE_PLAYABLE && range.1 > 0) && predictedStreamDuration > 0 Log.debug("loaded \(range), numberOfBuffersScheduledInTotal: \(numberOfBuffersScheduledInTotal), isPlayable: \(isPlayable)") bufferedSeconds = SAAudioAvailabilityRange(startingNeedle: range.0, durationLoadedByNetwork: range.1, predictedDurationToLoad: predictedStreamDuration, isPlayable: isPlayable) } - + private func updateNeedle() { guard engine.isRunning else { return } - + guard let nodeTime = playerNode.lastRenderTime, - let playerTime = playerNode.playerTime(forNodeTime: nodeTime) else { - return + let playerTime = playerNode.playerTime(forNodeTime: nodeTime) + else { + return } - - //NOTE: playerTime can sometimes be < 0 when seeking. Reason pasted below - //"The usual AVAudioNode sample times (as observed by lastRenderTime ) have an arbitrary zero point. - //AVAudioPlayerNode superimposes a second “player timeline” on top of this, to reflect when the - //player was started, and intervals during which it was paused." + + // NOTE: playerTime can sometimes be < 0 when seeking. Reason pasted below + // "The usual AVAudioNode sample times (as observed by lastRenderTime ) have an arbitrary zero point. + // AVAudioPlayerNode superimposes a second “player timeline” on top of this, to reflect when the + // player was started, and intervals during which it was paused." var currentTime = TimeInterval(playerTime.sampleTime) / playerTime.sampleRate currentTime = currentTime > 0 ? currentTime : 0 - + needle = (currentTime + currentTimeOffset) } - + private func updateDuration() { if let d = converter.pollPredictedDuration() { - self.predictedStreamDuration = d + predictedStreamDuration = d } } - - - //MARK:- Overriden From Parent + + // MARK: - Overriden From Parent + override func seek(toNeedle needle: Needle) { Log.info("didSeek to needle: \(needle)") @@ -282,21 +283,21 @@ class AudioStreamEngine: AudioEngine { return } - guard needle < (ceil(predictedStreamDuration)) else { + guard needle < ceil(predictedStreamDuration) else { if !isPlayable { seekNeedleCommandBeforeEngineWasReady = needle } Log.error("tried to seek beyond duration") return } - - self.needle = needle //to tick while paused - + + self.needle = needle // to tick while paused + queue.sync { [weak self] in self?.seekHelperDispatchQueue(needle: needle) } } - + /** The UI would freeze when we tried to call playerNode.stop() while simultaneously filling a buffer on another thread. Solution was to put @@ -304,45 +305,45 @@ class AudioStreamEngine: AudioEngine { */ private func seekHelperDispatchQueue(needle: Needle) { wasPlaying = playerNode.isPlaying - - //NOTE: Order matters - //seek needs to be called before stop - //Why? Stop will clear all buffers. Each buffer being cleared - //will call the callback which then fills the buffers with things to the - //right of the needle. If the order of these two were reversed we would - //schedule things to the right of the old needle then actually schedule everything - //after the new needle - //We also need to poll right after the seek to give us more buffers + + // NOTE: Order matters + // seek needs to be called before stop + // Why? Stop will clear all buffers. Each buffer being cleared + // will call the callback which then fills the buffers with things to the + // right of the needle. If the order of these two were reversed we would + // schedule things to the right of the old needle then actually schedule everything + // after the new needle + // We also need to poll right after the seek to give us more buffers converter.seek(needle) currentTimeOffset = TimeInterval(needle) - + playerNode.stop() - + shouldPollForNextBuffer = true - + updateNetworkBufferRange() } - + override func pause() { queue.async { [weak self] in self?.pauseHelperDispatchQueue() } } - + private func pauseHelperDispatchQueue() { super.pause() } - + override func play() { queue.async { [weak self] in self?.playHelperDispatchQueue() } } - + private func playHelperDispatchQueue() { super.play() } - + override func invalidate() { queue.sync { [weak self] in self?.invalidateHelperDispatchQueue() diff --git a/Source/Engine/AudioThrottler.swift b/Source/Engine/AudioThrottler.swift index a2517f9..76fee2c 100644 --- a/Source/Engine/AudioThrottler.swift +++ b/Source/Engine/AudioThrottler.swift @@ -31,7 +31,7 @@ protocol AudioThrottleDelegate: AnyObject { protocol AudioThrottleable { init(withRemoteUrl url: AudioURL, withDelegate delegate: AudioThrottleDelegate) - func pullNextDataPacket(_ callback: @escaping (Data?) -> ()) + func pullNextDataPacket(_ callback: @escaping (Data?) -> Void) func tellSeek(offset: UInt64) func pollRangeOfBytesAvailable() -> (UInt64, UInt64) func invalidate() @@ -39,22 +39,23 @@ protocol AudioThrottleable { class AudioThrottler: AudioThrottleable { private let queue = DispatchQueue(label: "SwiftAudioPlayer.Throttler", qos: .userInitiated) - - //Init + + // Init let url: AudioURL weak var delegate: AudioThrottleDelegate? - + private var networkData: [Data] = [] { didSet { // Log.test("NETWORK DATA \(networkData.count)") } } + private var lastSentDataPacketIndex = -1 - + var shouldThrottle = false var byteOffsetBecauseOfSeek: UInt = 0 - - //This will be sent once at beginning of stream and every network seek + + // This will be sent once at beginning of stream and every network seek var totalBytesExpected: Int64? { didSet { if let bytes = totalBytesExpected { @@ -62,21 +63,21 @@ class AudioThrottler: AudioThrottleable { } } } - + var largestPollingOffsetDifference: UInt64 = 1 - + required init(withRemoteUrl url: AudioURL, withDelegate delegate: AudioThrottleDelegate) { self.url = url self.delegate = delegate - + AudioDataManager.shared.startStream(withRemoteURL: url) { [weak self] (pto: StreamProgressPTO) in - guard let self = self else {return} + guard let self = self else { return } Log.debug("received stream data of size \(pto.getData().count) and progress: \(pto.getProgress())") if let totalBytesExpected = pto.getTotalBytesExpected() { self.totalBytesExpected = totalBytesExpected } - + self.queue.async { [weak self] in self?.networkData.append(pto.getData()) StreamingDownloadDirector.shared.didUpdate(url.key, networkStreamProgress: pto.getProgress()) @@ -84,68 +85,67 @@ class AudioThrottler: AudioThrottleable { } } - func tellSeek(offset: UInt64) { Log.info("seek with offset: \(offset)") - - self.queue.async { [weak self] in + + queue.async { [weak self] in self?.seekQueueHelper(offset) } } - + func seekQueueHelper(_ offset: UInt64) { let offsetToFind = Int(offset) - Int(byteOffsetBecauseOfSeek) - - var shouldStartNewStream: Bool = false - + + var shouldStartNewStream = false + // if we have no data start a new stream after seek if networkData.count == 0 { shouldStartNewStream = true } - + // if what we're looking for is outside of available data, start a new stream if offset < byteOffsetBecauseOfSeek || offsetToFind > networkData.sum { shouldStartNewStream = true } - + // we should have the data within our cache. find it and save the index for the next pull if let indexOfDataContainingOffset = networkData.getIndexContainingByteOffset(offsetToFind) { lastSentDataPacketIndex = indexOfDataContainingOffset - 1 } - + if shouldStartNewStream { byteOffsetBecauseOfSeek = UInt(offset) lastSentDataPacketIndex = -1 AudioDataManager.shared.seekStream(withRemoteURL: url, toByteOffset: offset) - + networkData = [] return } - + Log.error("83672 Should not get here") } - + func pollRangeOfBytesAvailable() -> (UInt64, UInt64) { let start = byteOffsetBecauseOfSeek let end = networkData.sum + Int(byteOffsetBecauseOfSeek) - + return (UInt64(start), UInt64(end)) } - - func pullNextDataPacket(_ callback: @escaping (Data?) -> ()) { + + func pullNextDataPacket(_ callback: @escaping (Data?) -> Void) { queue.async { [weak self] in guard let self = self else { return } guard self.lastSentDataPacketIndex < self.networkData.count - 1 else { callback(nil) return } - + self.lastSentDataPacketIndex += 1 - + callback(self.networkData[self.lastSentDataPacketIndex]) } } - + func invalidate() { AudioDataManager.shared.deleteStream(withRemoteURL: url) } @@ -153,23 +153,21 @@ class AudioThrottler: AudioThrottleable { extension Array where Element == Data { var sum: Int { - get { - guard count > 0 else { return 0 } - return self.reduce(0) { $0 + $1.count } - } + guard count > 0 else { return 0 } + return reduce(0) { $0 + $1.count } } - + func getIndexContainingByteOffset(_ offset: Int) -> Int? { var dataCount = 0 - - for (i, data) in self.enumerated() { - if offset >= dataCount && offset <= dataCount + data.count { + + for (i, data) in enumerated() { + if offset >= dataCount, offset <= dataCount + data.count { return i } - + dataCount += data.count } - + return nil } } diff --git a/Source/Engine/Converter/AudioConverter.swift b/Source/Engine/Converter/AudioConverter.swift index 5670c7f..59c56ef 100644 --- a/Source/Engine/Converter/AudioConverter.swift +++ b/Source/Engine/Converter/AudioConverter.swift @@ -29,13 +29,13 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation -import AVFoundation import AudioToolbox +import AVFoundation +import Foundation protocol AudioConvertable { - var engineAudioFormat: AVAudioFormat {get} - + var engineAudioFormat: AVAudioFormat { get } + init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat, withPCMBufferSize size: AVAudioFrameCount) throws func pullBuffer() throws -> AVAudioPCMBuffer func pollPredictedDuration() -> Duration? @@ -46,17 +46,17 @@ protocol AudioConvertable { /** Creates PCM Buffers for the audio engine - + Main Responsibilities: - + CREATE CONVERTER. Waits for parser to give back audio format then creates a converter. - + USE CONVERTER. The converter takes parsed audio packets and 1. transforms them into a format that the engine can take. 2. Fills a buffer of a certain size. Note that we might not need a converted if the format that the engine takes in is the same as what the parser outputs. - + KEEP AUDIO INDEX: The engine keeps trying to pull a buffer from converter. The converter will keep pulling from parser. The converter calculates the exact index that it wants to convert and keeps pulling at that index until the parser @@ -64,35 +64,35 @@ protocol AudioConvertable { */ class AudioConverter: AudioConvertable { let queue = DispatchQueue(label: "SwiftAudioPlayer.audio_reader_queue") - - //From Init + + // From Init var parser: AudioParsable! - - //From protocol + + // From protocol public var engineAudioFormat: AVAudioFormat let pcmBufferSize: AVAudioFrameCount - - //Field - var converter: AudioConverterRef? //set by AudioConverterNew + + // Field + var converter: AudioConverterRef? // set by AudioConverterNew var currentAudioPacketIndex: AVAudioPacketCount = 0 - + // use to store reference to the allocated buffers from the converter to properly deallocate them before the next packet is being converted var converterBuffer: UnsafeMutableRawPointer? var converterDescriptions: UnsafeMutablePointer? - + required init(withRemoteUrl url: AudioURL, toEngineAudioFormat: AVAudioFormat, withPCMBufferSize size: AVAudioFrameCount) throws { - self.engineAudioFormat = toEngineAudioFormat - self.pcmBufferSize = size - + engineAudioFormat = toEngineAudioFormat + pcmBufferSize = size + do { parser = try AudioParser(withRemoteUrl: url, bufferSize: Int(size), parsedFileAudioFormatCallback: { [weak self] (fileAudioFormat: AVAudioFormat) in guard let strongSelf = self else { return } - + let sourceFormat = fileAudioFormat.streamDescription let destinationFormat = strongSelf.engineAudioFormat.streamDescription let result = AudioConverterNew(sourceFormat, destinationFormat, &strongSelf.converter) - + guard result == noErr else { Log.monitor(ConverterError.unableToCreateConverter(result).errorDescription as Any) return @@ -102,31 +102,31 @@ class AudioConverter: AudioConvertable { throw ConverterError.failedToCreateParser } } - + deinit { guard let converter = converter else { Log.error("No converter n deinit!") return } - + guard AudioConverterDispose(converter) == noErr else { Log.monitor("failed to dispose audio converter") return } } - + func pullBuffer() throws -> AVAudioPCMBuffer { guard let converter = converter else { Log.debug("reader_error trying to read before converter has been created") throw ConverterError.cannotCreatePCMBufferWithoutConverter } - + guard let pcmBuffer = AVAudioPCMBuffer(pcmFormat: engineAudioFormat, frameCapacity: pcmBufferSize) else { Log.monitor(ConverterError.failedToCreatePCMBuffer.errorDescription as Any) throw ConverterError.failedToCreatePCMBuffer } pcmBuffer.frameLength = pcmBufferSize - + /** The whole thing is wrapped in queue.sync() because the converter listener needs to eventually increment the audioPatcketIndex. We don't want threads @@ -135,10 +135,10 @@ class AudioConverter: AudioConvertable { return try queue.sync { () -> AVAudioPCMBuffer in let framesPerPacket = engineAudioFormat.streamDescription.pointee.mFramesPerPacket var numberOfPacketsWeWantTheBufferToFill = pcmBuffer.frameLength / framesPerPacket - + let context = unsafeBitCast(self, to: UnsafeMutableRawPointer.self) let status = AudioConverterFillComplexBuffer(converter, ConverterListener, context, &numberOfPacketsWeWantTheBufferToFill, pcmBuffer.mutableAudioBufferList, nil) - + guard status == noErr else { switch status { case ReaderMissingSourceFormatError: @@ -156,7 +156,7 @@ class AudioConverter: AudioConvertable { return pcmBuffer } } - + func seek(_ needle: Needle) { guard let audioPacketIndex = getPacketIndex(forNeedle: needle) else { return @@ -167,19 +167,19 @@ class AudioConverter: AudioConvertable { parser.tellSeek(toIndex: audioPacketIndex) } } - + func pollPredictedDuration() -> Duration? { return parser.predictedDuration } - + func pollNetworkAudioAvailabilityRange() -> (Needle, Duration) { return parser.pollRangeOfSecondsAvailableFromNetwork() } - + func invalidate() { parser.invalidate() } - + private func getPacketIndex(forNeedle needle: Needle) -> AVAudioPacketCount? { guard needle >= 0 else { Log.error("needle should never be a negative number! needle received: \(needle)") @@ -189,7 +189,7 @@ class AudioConverter: AudioConvertable { guard let framesPerPacket = parser.fileAudioFormat?.streamDescription.pointee.mFramesPerPacket else { return nil } return AVAudioPacketCount(frame) / AVAudioPacketCount(framesPerPacket) } - + private func frameOffset(forTime time: TimeInterval) -> AVAudioFramePosition? { guard let _ = parser.fileAudioFormat?.streamDescription.pointee, let frameCount = parser.totalPredictedAudioFrameCount, let duration = parser.predictedDuration else { return nil } let ratio = time / duration diff --git a/Source/Engine/Converter/AudioConverterErrors.swift b/Source/Engine/Converter/AudioConverterErrors.swift index 4ed5cca..bed5895 100644 --- a/Source/Engine/Converter/AudioConverterErrors.swift +++ b/Source/Engine/Converter/AudioConverterErrors.swift @@ -29,16 +29,15 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation -import AVFoundation import AudioToolbox +import AVFoundation +import Foundation - -let ReaderReachedEndOfDataError: OSStatus = 932332581 -let ReaderNotEnoughDataError: OSStatus = 932332582 -let ReaderMissingSourceFormatError: OSStatus = 932332583 -let ReaderMissingParserError: OSStatus = 932332584 -let ReaderShouldNotHappenError: OSStatus = 932332585 +let ReaderReachedEndOfDataError: OSStatus = 932_332_581 +let ReaderNotEnoughDataError: OSStatus = 932_332_582 +let ReaderMissingSourceFormatError: OSStatus = 932_332_583 +let ReaderMissingParserError: OSStatus = 932_332_584 +let ReaderShouldNotHappenError: OSStatus = 932_332_585 public enum ConverterError: LocalizedError { case cannotLockQueue @@ -53,13 +52,13 @@ public enum ConverterError: LocalizedError { case superConcerningShouldNeverHappen case throttleParsingBuffersForEngine case failedToCreateParser - + public var errorDescription: String? { switch self { case .cannotLockQueue: Log.warn("Failed to lock queue") return "Failed to lock queue" - case .converterFailed(let status): + case let .converterFailed(status): Log.warn(localizedDescriptionFromConverterError(status)) return localizedDescriptionFromConverterError(status) case .failedToCreateDestinationFormat: @@ -77,7 +76,7 @@ public enum ConverterError: LocalizedError { case .reachedEndOfFile: Log.warn("Reached the end of the file") return "Reached the end of the file" - case .unableToCreateConverter(let status): + case let .unableToCreateConverter(status): return localizedDescriptionFromConverterError(status) case .superConcerningShouldNeverHappen: Log.warn("Weird unexpected reader error. Should not have happened") @@ -93,7 +92,7 @@ public enum ConverterError: LocalizedError { return "Could not create a parser" } } - + func localizedDescriptionFromConverterError(_ status: OSStatus) -> String { switch status { case kAudioConverterErr_FormatNotSupported: diff --git a/Source/Engine/Converter/AudioConverterListener.swift b/Source/Engine/Converter/AudioConverterListener.swift index 656c16f..a6e31c1 100644 --- a/Source/Engine/Converter/AudioConverterListener.swift +++ b/Source/Engine/Converter/AudioConverterListener.swift @@ -29,62 +29,62 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation -import AVFoundation import AudioToolbox +import AVFoundation +import Foundation -func ConverterListener(_ converter: AudioConverterRef, _ packetCount: UnsafeMutablePointer, _ ioData: UnsafeMutablePointer, _ outPacketDescriptions: UnsafeMutablePointer?>?, _ context: UnsafeMutableRawPointer?) -> OSStatus { +func ConverterListener(_: AudioConverterRef, _ packetCount: UnsafeMutablePointer, _ ioData: UnsafeMutablePointer, _ outPacketDescriptions: UnsafeMutablePointer?>?, _ context: UnsafeMutableRawPointer?) -> OSStatus { let selfAudioConverter = Unmanaged.fromOpaque(context!).takeUnretainedValue() - + guard let parser = selfAudioConverter.parser else { Log.monitor("ReaderMissingParserError") return ReaderMissingParserError } - + guard let fileAudioFormat = parser.fileAudioFormat else { Log.monitor("ReaderMissingSourceFormatError") return ReaderMissingSourceFormatError } - - var audioPacketFromParser:(AudioStreamPacketDescription?, Data)? + + var audioPacketFromParser: (AudioStreamPacketDescription?, Data)? do { audioPacketFromParser = try parser.pullPacket(atIndex: selfAudioConverter.currentAudioPacketIndex) Log.debug("received packet from parser at index: \(selfAudioConverter.currentAudioPacketIndex)") } catch ParserError.notEnoughDataForReader { return ReaderNotEnoughDataError } catch ParserError.readerAskingBeyondEndOfFile { - //On output, the number of packets of audio data provided for conversion, - //or 0 if there is no more data to convert. + // On output, the number of packets of audio data provided for conversion, + // or 0 if there is no more data to convert. packetCount.pointee = 0 return ReaderReachedEndOfDataError } catch { return ReaderShouldNotHappenError } - + guard let audioPacket = audioPacketFromParser else { return ReaderShouldNotHappenError } - + if let lastBuffer = selfAudioConverter.converterBuffer { lastBuffer.deallocate() } - + // Copy data over (note we've only processing a single packet of data at a time) var packet = audioPacket.1 - let packetByteCount = packet.count //this is not the count of an array + let packetByteCount = packet.count // this is not the count of an array ioData.pointee.mNumberBuffers = 1 ioData.pointee.mBuffers.mData = UnsafeMutableRawPointer.allocate(byteCount: packetByteCount, alignment: 0) - _ = packet.accessMutableBytes({ (bytes: UnsafeMutablePointer) in + _ = packet.accessMutableBytes { (bytes: UnsafeMutablePointer) in memcpy((ioData.pointee.mBuffers.mData?.assumingMemoryBound(to: UInt8.self))!, bytes, packetByteCount) - }) + } ioData.pointee.mBuffers.mDataByteSize = UInt32(packetByteCount) - + selfAudioConverter.converterBuffer = ioData.pointee.mBuffers.mData - + if let lastDescription = selfAudioConverter.converterDescriptions { lastDescription.deallocate() } - + // Handle packet descriptions for compressed formats (MP3, AAC, etc) let fileFormatDescription = fileAudioFormat.streamDescription.pointee if fileFormatDescription.mFormatID != kAudioFormatLinearPCM { @@ -95,13 +95,13 @@ func ConverterListener(_ converter: AudioConverterRef, _ packetCount: UnsafeMuta outPacketDescriptions?.pointee?.pointee.mStartOffset = 0 outPacketDescriptions?.pointee?.pointee.mVariableFramesInPacket = 0 } - + selfAudioConverter.converterDescriptions = outPacketDescriptions?.pointee - + packetCount.pointee = 1 - - //we've successfully given a packet to the LPCM buffer now we can process the next audio packet + + // we've successfully given a packet to the LPCM buffer now we can process the next audio packet selfAudioConverter.currentAudioPacketIndex = selfAudioConverter.currentAudioPacketIndex + 1 - + return noErr } diff --git a/Source/Engine/Parser/AudioParsable.swift b/Source/Engine/Parser/AudioParsable.swift index 753e7a3..f9df664 100644 --- a/Source/Engine/Parser/AudioParsable.swift +++ b/Source/Engine/Parser/AudioParsable.swift @@ -29,28 +29,27 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation -protocol AudioParsable { //For the layer above us - var fileAudioFormat: AVAudioFormat? {get} +protocol AudioParsable { // For the layer above us + var fileAudioFormat: AVAudioFormat? { get } var totalPredictedPacketCount: AVAudioPacketCount { get } func tellSeek(toIndex index: AVAudioPacketCount) func pollRangeOfSecondsAvailableFromNetwork() -> (Needle, Duration) func pullPacket(atIndex index: AVAudioPacketCount) throws -> (AudioStreamPacketDescription?, Data) - func invalidate() //deinit caused concurrency problems + func invalidate() // deinit caused concurrency problems } -extension AudioParsable { //For the layer above us +extension AudioParsable { // For the layer above us var predictedDuration: Duration? { guard let sampleRate = fileAudioFormat?.sampleRate else { return nil } guard let totalPredictedFrameCount = totalPredictedAudioFrameCount else { return nil } - return Duration(totalPredictedFrameCount)/Duration(sampleRate) + return Duration(totalPredictedFrameCount) / Duration(sampleRate) } - - + var totalPredictedAudioFrameCount: AUAudioFrameCount? { - guard let framesPerPacket = fileAudioFormat?.streamDescription.pointee.mFramesPerPacket else {return nil } + guard let framesPerPacket = fileAudioFormat?.streamDescription.pointee.mFramesPerPacket else { return nil } return AVAudioFrameCount(totalPredictedPacketCount) * AVAudioFrameCount(framesPerPacket) } } diff --git a/Source/Engine/Parser/AudioParser.swift b/Source/Engine/Parser/AudioParser.swift index c486722..03a35f2 100644 --- a/Source/Engine/Parser/AudioParser.swift +++ b/Source/Engine/Parser/AudioParser.swift @@ -29,34 +29,34 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation /** DEFINITIONS - + An audio stream is a continuous series of data that represents a sound, such as a song. - + A channel is a discrete track of monophonic audio. A monophonic stream has one channel; a stereo stream has two channels. - + A sample is single numerical value for a single audio channel in an audio stream. - + A frame is a collection of time-coincident samples. For instance, a linear PCM stereo sound file has two samples per frame, one for the left channel and one for the right channel. - + A packet is a collection of one or more contiguous frames. A packet defines the smallest meaningful set of frames for a given audio data format, and is the smallest data unit for which time can be measured. In linear PCM audio, a packet holds a single frame. In compressed formats, it typically holds more; in some formats, the number of frames per packet varies. - + The sample rate for a stream is the number of frames per second of uncompressed (or, for compressed formats, the equivalent in decompressed) audio. - - */ + */ -//TODO: what if user seeks beyond the data we have? What if we're done but user seeks even further than what we have +// TODO: what if user seeks beyond the data we have? What if we're done but user seeks even further than what we have class AudioParser: AudioParsable { - private var MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING = 8192 // this will be modified when we know the file format to be just enough packets to fill up 1 pcm buffer + private var MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING = 8192 // this will be modified when we know the file format to be just enough packets to fill up 1 pcm buffer private var framesPerBuffer: Int = 1 - - //MARK:- For OS parser class + + // MARK: - For OS parser class + var parsedAudioHeaderPacketCount: UInt64 = 0 var parsedAudioPacketDataSize: UInt64 = 0 var parsedAudioDataOffset: UInt64 = 0 @@ -64,63 +64,65 @@ class AudioParser: AudioParsable { public var fileAudioFormat: AVAudioFormat? { didSet { if let format = fileAudioFormat, oldValue == nil { - MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING = framesPerBuffer/Int(format.streamDescription.pointee.mFramesPerPacket) + MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING = framesPerBuffer / Int(format.streamDescription.pointee.mFramesPerPacket) parsedFileAudioFormatCallback(format) } } } - - //MARK:- Our vars - //Init + + // MARK: - Our vars + + // Init let url: AudioURL var throttler: AudioThrottleable! - - //Our use + + // Our use var expectedFileSizeInBytes: UInt64? var networkProgress: Double = 0 - var parsedFileAudioFormatCallback: (AVAudioFormat) -> () + var parsedFileAudioFormatCallback: (AVAudioFormat) -> Void var indexSeekOffset: AVAudioPacketCount = 0 var shouldPreventPacketFromFillingUp = false - + public var totalPredictedPacketCount: AVAudioPacketCount { if parsedAudioHeaderPacketCount != 0 { - //TODO: we should log the duration to the server for better user experience + // TODO: we should log the duration to the server for better user experience return max(AVAudioPacketCount(parsedAudioHeaderPacketCount), AVAudioPacketCount(audioPackets.count)) } - + let sizeOfFileInBytes: UInt64 = expectedFileSizeInBytes != nil ? expectedFileSizeInBytes! : 0 - + guard let bytesPerPacket = averageBytesPerPacket else { return AVAudioPacketCount(0) } - + let predictedCount = AVAudioPacketCount(Double(sizeOfFileInBytes) / bytesPerPacket) - + guard networkProgress != 1.0 else { return max(AVAudioPacketCount(audioPackets.count), predictedCount) } - + return predictedCount } - - var sumOfParsedAudioBytes:UInt32 = 0 - var numberOfPacketsParsed:UInt32 = 0 - var audioPackets: [(AudioStreamPacketDescription?,Data)] = [] { + + var sumOfParsedAudioBytes: UInt32 = 0 + var numberOfPacketsParsed: UInt32 = 0 + var audioPackets: [(AudioStreamPacketDescription?, Data)] = [] { didSet { if let audioPacketByteSize = audioPackets.last?.0?.mDataByteSize { sumOfParsedAudioBytes += audioPacketByteSize } else if let audioPacketByteSize = audioPackets.last?.1.count { // for uncompressed audio there are no descriptors to say how many bytes of audio are in this packet so we approximate by data size sumOfParsedAudioBytes += UInt32(audioPacketByteSize) } - + numberOfPacketsParsed += 1 - - //TODO: duration will not be accurate with WAV or AIFF + + // TODO: duration will not be accurate with WAV or AIFF } } + private let lockQueue = DispatchQueue(label: "SwiftAudioPlayer.Parser.packets.lock") var lastSentAudioPacketIndex = -1 - + /** Audio packets varry in size. The first one parsed in a batch of audio packets is usually off by 1 from the others. We use the @@ -130,35 +132,35 @@ class AudioParser: AudioParsable { podcasts. Since Double->Int is floored the parser would ask for byte 979312 but that spot is actually suppose to be 982280 from the throttler's perspective */ - var averageBytesPerPacket:Double? { + var averageBytesPerPacket: Double? { if numberOfPacketsParsed == 0 { return nil } - - return Double(sumOfParsedAudioBytes)/Double(numberOfPacketsParsed) + + return Double(sumOfParsedAudioBytes) / Double(numberOfPacketsParsed) } - + var isParsingComplete: Bool { guard fileAudioFormat != nil else { return false } - //TODO: will this ever return true? Predicted uses MAX of prediction of total packet length + // TODO: will this ever return true? Predicted uses MAX of prediction of total packet length return audioPackets.count == totalPredictedPacketCount } - + var streamChangeListenerId: UInt? - - init(withRemoteUrl url: AudioURL, bufferSize: Int, parsedFileAudioFormatCallback: @escaping(AVAudioFormat) -> ()) throws { + + init(withRemoteUrl url: AudioURL, bufferSize: Int, parsedFileAudioFormatCallback: @escaping (AVAudioFormat) -> Void) throws { self.url = url - self.framesPerBuffer = bufferSize + framesPerBuffer = bufferSize self.parsedFileAudioFormatCallback = parsedFileAudioFormatCallback - - self.throttler = AudioThrottler(withRemoteUrl: url, withDelegate: self) - - streamChangeListenerId = StreamingDownloadDirector.shared.attach { [weak self] (progress) in + + throttler = AudioThrottler(withRemoteUrl: url, withDelegate: self) + + streamChangeListenerId = StreamingDownloadDirector.shared.attach { [weak self] progress in guard let self = self else { return } self.networkProgress = progress - + // initially parse a bunch of packets self.lockQueue.sync { if self.fileAudioFormat == nil { @@ -168,29 +170,29 @@ class AudioParser: AudioParsable { } } } - + let context = unsafeBitCast(self, to: UnsafeMutableRawPointer.self) - //Open the stream and when we call parse data is fed into this stream + // Open the stream and when we call parse data is fed into this stream guard AudioFileStreamOpen(context, ParserPropertyListener, ParserPacketListener, kAudioFileMP3Type, &streamID) == noErr else { throw ParserError.couldNotOpenStream } } - + deinit { if let id = streamChangeListenerId { StreamingDownloadDirector.shared.detach(withID: id) } } - + func pullPacket(atIndex index: AVAudioPacketCount) throws -> (AudioStreamPacketDescription?, Data) { determineIfMoreDataNeedsToBeParsed(index: index) - + // Check if we've reached the end of the packets. We have two scenarios: // 1. We've reached the end of the packet data and the file has been completely parsed // 2. We've reached the end of the data we currently have downloaded, but not the file let packetIndex = index - indexSeekOffset - - var exception: ParserError? = nil + + var exception: ParserError? var packet: (AudioStreamPacketDescription?, Data) = (nil, Data()) lockQueue.sync { if packetIndex >= self.audioPackets.count { @@ -203,7 +205,7 @@ class AudioParser: AudioParsable { return } } - + lastSentAudioPacketIndex = Int(packetIndex) packet = audioPackets[Int(packetIndex)] } @@ -213,7 +215,7 @@ class AudioParser: AudioParsable { return packet } } - + private func determineIfMoreDataNeedsToBeParsed(index: AVAudioPacketCount) { lockQueue.sync { if index > self.audioPackets.count - self.MIN_PACKETS_TO_HAVE_AVAILABLE_BEFORE_THROTTLING_PARSING { @@ -221,123 +223,121 @@ class AudioParser: AudioParsable { } } } - + func tellSeek(toIndex index: AVAudioPacketCount) { - //Already within the processed audio packets. Ignore - var isIndexValid: Bool = true + // Already within the processed audio packets. Ignore + var isIndexValid = true lockQueue.sync { - if self.indexSeekOffset <= index && index < self.audioPackets.count + Int(self.indexSeekOffset) { + if self.indexSeekOffset <= index, index < self.audioPackets.count + Int(self.indexSeekOffset) { isIndexValid = false } } guard isIndexValid else { return } - + guard let byteOffset = getOffset(fromPacketIndex: index) else { return } Log.info("did not have processed audio for index: \(index) / offset: \(byteOffset)") - + indexSeekOffset = index - + // NOTE: Order matters. Need to prevent appending to the array before we clean it. Just in case // then we tell the throttler to send us appropriate packet shouldPreventPacketFromFillingUp = true lockQueue.sync { self.audioPackets = [] } - + throttler.tellSeek(offset: byteOffset) - self.processNextDataPacket() + processNextDataPacket() } - + private func getOffset(fromPacketIndex index: AVAudioPacketCount) -> UInt64? { - //Clear current buffer if we have audio format - guard fileAudioFormat != nil, let bytesPerPacket = self.averageBytesPerPacket else { - Log.error("should not get here \(String(describing: fileAudioFormat)) and \(String(describing: self.averageBytesPerPacket))") + // Clear current buffer if we have audio format + guard fileAudioFormat != nil, let bytesPerPacket = averageBytesPerPacket else { + Log.error("should not get here \(String(describing: fileAudioFormat)) and \(String(describing: averageBytesPerPacket))") return nil } - + return UInt64(Double(index) * bytesPerPacket) + parsedAudioDataOffset } - + func pollRangeOfSecondsAvailableFromNetwork() -> (Needle, Duration) { let range = throttler.pollRangeOfBytesAvailable() - + let startPacket = getPacket(fromOffset: range.0) != nil ? getPacket(fromOffset: range.0)! : 0 - + guard let startFrame = getFrame(forPacket: startPacket), let startNeedle = getNeedle(forFrame: startFrame) else { return (0, 0) } - + guard let endPacket = getPacket(fromOffset: range.1), let endFrame = getFrame(forPacket: endPacket), let endNeedle = getNeedle(forFrame: endFrame) else { return (0, 0) } - + return (startNeedle, Duration(endNeedle)) } - + private func getPacket(fromOffset offset: UInt64) -> AVAudioPacketCount? { - guard fileAudioFormat != nil, let bytesPerPacket = self.averageBytesPerPacket else { return nil } + guard fileAudioFormat != nil, let bytesPerPacket = averageBytesPerPacket else { return nil } let audioDataBytes = Int(offset) - Int(parsedAudioDataOffset) - + guard audioDataBytes > 0 else { // Because we error out if we try to set a negative number as AVAudioPacketCount which is a UInt32 return nil } - + return AVAudioPacketCount(Double(audioDataBytes) / bytesPerPacket) } - + private func getFrame(forPacket packet: AVAudioPacketCount) -> AVAudioFrameCount? { guard let framesPerPacket = fileAudioFormat?.streamDescription.pointee.mFramesPerPacket else { return nil } return packet * framesPerPacket } - + private func getNeedle(forFrame frame: AVAudioFrameCount) -> Needle? { guard let _ = fileAudioFormat?.streamDescription.pointee, let frameCount = totalPredictedAudioFrameCount, let duration = predictedDuration else { return nil } - + guard duration > 0 else { return nil } - - return Needle(TimeInterval(frame)/TimeInterval(frameCount)*duration) + + return Needle(TimeInterval(frame) / TimeInterval(frameCount) * duration) } - + func append(description: AudioStreamPacketDescription?, data: Data) { lockQueue.sync { self.audioPackets.append((description, data)) } } - + func invalidate() { throttler.invalidate() - - //FIXME: See Note below. Don't remove this until the problem has been properly solved - //if let sId = streamID { + + // FIXME: See Note below. Don't remove this until the problem has been properly solved + // if let sId = streamID { // let result = AudioFileStreamClose(sId) // if result != noErr { // Log.monitor("parser_error", ParserError.failedToParseBytes(result).errorDescription) // } - //} + // } /** We saw a bad access in the parser. We think this is because AudioFileStreamClose is called before the parser finished parsing a set of networkPackets. - + Three solutions we thought of: 1. Make parser a singleton and have callbacks that use and ID 2. Do some math on network data size and parsed packets. The parsed packets get 99.9% to the network data 3. Uncomment AudioFileStreamClose. There will be potential memory leaks - + We chose option 3 because: + we looked at memory hit and it was neglegible + simplest solution – we might forget about commenting this out and run into a bug */ - - } - + private func processNextDataPacket() { - throttler.pullNextDataPacket { [weak self] (d) in + throttler.pullNextDataPacket { [weak self] d in guard let self = self else { return } guard let data = d else { return } - + self.lockQueue.sync { Log.debug("processing data count: \(data.count) :: already had \(self.audioPackets.count) audio packets") } @@ -345,23 +345,23 @@ class AudioParser: AudioParsable { do { let sID = self.streamID! let dataSize = data.count - - _ = try data.accessBytes({ (bytes: UnsafePointer) in - let result:OSStatus = AudioFileStreamParseBytes(sID, UInt32(dataSize), bytes, []) + + _ = try data.accessBytes { (bytes: UnsafePointer) in + let result: OSStatus = AudioFileStreamParseBytes(sID, UInt32(dataSize), bytes, []) guard result == noErr else { Log.monitor(ParserError.failedToParseBytes(result).errorDescription as Any) throw ParserError.failedToParseBytes(result) } - }) + } } catch { Log.monitor(error.localizedDescription) } } } - } -//MARK:- AudioThrottleDelegate +// MARK: - AudioThrottleDelegate + extension AudioParser: AudioThrottleDelegate { func didUpdate(totalBytesExpected bytes: Int64) { expectedFileSizeInBytes = UInt64(bytes) diff --git a/Source/Engine/Parser/AudioParserErrors.swift b/Source/Engine/Parser/AudioParserErrors.swift index aac3ade..df90063 100644 --- a/Source/Engine/Parser/AudioParserErrors.swift +++ b/Source/Engine/Parser/AudioParserErrors.swift @@ -29,20 +29,20 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation enum ParserError: LocalizedError { case couldNotOpenStream case failedToParseBytes(OSStatus) case notEnoughDataForReader case readerAskingBeyondEndOfFile - + var errorDescription: String? { switch self { case .couldNotOpenStream: return "Could not open stream for parsing" - case .failedToParseBytes(let status): + case let .failedToParseBytes(status): return localizedDescriptionFromParseError(status) case .notEnoughDataForReader: return "Not enough data for reader. Will attemp to seek" @@ -50,7 +50,7 @@ enum ParserError: LocalizedError { return "Reader asking for packets beyond the end of file" } } - + func localizedDescriptionFromParseError(_ status: OSStatus) -> String { switch status { case kAudioFileStreamError_UnsupportedFileType: @@ -79,10 +79,9 @@ enum ParserError: LocalizedError { } } - /// This extension just helps us print out the name of an `AudioFileStreamPropertyID`. Purely for debugging and not essential to the main functionality of the parser. -extension AudioFileStreamPropertyID { - public var description: String { +public extension AudioFileStreamPropertyID { + var description: String { switch self { case kAudioFileStreamProperty_ReadyToProducePackets: return "Ready to produce packets" diff --git a/Source/Engine/Parser/AudioParserPacketListener.swift b/Source/Engine/Parser/AudioParserPacketListener.swift index a728de0..eb6e033 100644 --- a/Source/Engine/Parser/AudioParserPacketListener.swift +++ b/Source/Engine/Parser/AudioParserPacketListener.swift @@ -29,36 +29,35 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation #if swift(>=5.3) -func ParserPacketListener (_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer?) { - parserPacket(context, byteCount, packetCount, streamData, packetDescriptions) -} + func ParserPacketListener(_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer?) { + parserPacket(context, byteCount, packetCount, streamData, packetDescriptions) + } #else -func ParserPacketListener (_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer) { - parserPacket(context, byteCount, packetCount, streamData, packetDescriptions) -} + func ParserPacketListener(_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer) { + parserPacket(context, byteCount, packetCount, streamData, packetDescriptions) + } #endif -func parserPacket(_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer?){ - +func parserPacket(_ context: UnsafeMutableRawPointer, _: UInt32, _ packetCount: UInt32, _ streamData: UnsafeRawPointer, _ packetDescriptions: UnsafeMutablePointer?) { let selfAudioParser = Unmanaged.fromOpaque(context).takeUnretainedValue() - + guard let fileAudioFormat = selfAudioParser.fileAudioFormat else { Log.monitor("should not have reached packet listener without a data format") return } - + guard selfAudioParser.shouldPreventPacketFromFillingUp == false else { Log.error("skipping parsing packets because of seek") return } - - //TODO refactor this after we get it working - if let compressedPacketDescriptions = packetDescriptions { // is compressed audio (.mp3) + + // TODO: refactor this after we get it working + if let compressedPacketDescriptions = packetDescriptions { // is compressed audio (.mp3) Log.debug("compressed audio") for i in 0 ..< Int(packetCount) { let audioPacketDescription = compressedPacketDescriptions[i] @@ -78,5 +77,4 @@ func parserPacket(_ context: UnsafeMutableRawPointer, _ byteCount: UInt32, _ pac selfAudioParser.append(description: nil, data: audioPacketData) } } - } diff --git a/Source/Engine/Parser/AudioParserPropertyListener.swift b/Source/Engine/Parser/AudioParserPropertyListener.swift index a1d0ac4..502a1a3 100644 --- a/Source/Engine/Parser/AudioParserPropertyListener.swift +++ b/Source/Engine/Parser/AudioParserPropertyListener.swift @@ -29,48 +29,43 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation - -func ParserPropertyListener(_ context: UnsafeMutableRawPointer, _ streamId: AudioFileStreamID, _ propertyId: AudioFileStreamPropertyID, _ flags: UnsafeMutablePointer) { +func ParserPropertyListener(_ context: UnsafeMutableRawPointer, _ streamId: AudioFileStreamID, _ propertyId: AudioFileStreamPropertyID, _: UnsafeMutablePointer) { let selfAudioParser = Unmanaged.fromOpaque(context).takeUnretainedValue() - + Log.info("audio file stream property: \(propertyId.description)") switch propertyId { case kAudioFileStreamProperty_DataFormat: var fileAudioFormat = AudioStreamBasicDescription() GetPropertyValue(&fileAudioFormat, streamId, propertyId) selfAudioParser.fileAudioFormat = AVAudioFormat(streamDescription: &fileAudioFormat) - break case kAudioFileStreamProperty_AudioDataPacketCount: GetPropertyValue(&selfAudioParser.parsedAudioHeaderPacketCount, streamId, propertyId) - break case kAudioFileStreamProperty_AudioDataByteCount: GetPropertyValue(&selfAudioParser.parsedAudioPacketDataSize, streamId, propertyId) selfAudioParser.expectedFileSizeInBytes = selfAudioParser.parsedAudioDataOffset + selfAudioParser.parsedAudioPacketDataSize - break; case kAudioFileStreamProperty_DataOffset: GetPropertyValue(&selfAudioParser.parsedAudioDataOffset, streamId, propertyId) - - if(selfAudioParser.parsedAudioPacketDataSize != 0) { + + if selfAudioParser.parsedAudioPacketDataSize != 0 { selfAudioParser.expectedFileSizeInBytes = selfAudioParser.parsedAudioDataOffset + selfAudioParser.parsedAudioPacketDataSize } - - break + default: break } } -//property is like the medatada of +// property is like the medatada of func GetPropertyValue(_ value: inout T, _ streamId: AudioFileStreamID, _ propertyId: AudioFileStreamPropertyID) { var propertySize: UInt32 = 0 - guard AudioFileStreamGetPropertyInfo(streamId, propertyId, &propertySize, nil) == noErr else {//try to get the size of the property + guard AudioFileStreamGetPropertyInfo(streamId, propertyId, &propertySize, nil) == noErr else { // try to get the size of the property Log.monitor("failed to get info for property:\(propertyId.description)") return } - + guard AudioFileStreamGetProperty(streamId, propertyId, &propertySize, &value) == noErr else { Log.monitor("failed to get propery value for: \(propertyId.description)") return diff --git a/Source/Engine/SAAudioAvailabilityRange.swift b/Source/Engine/SAAudioAvailabilityRange.swift index 7f76e3a..33d33dc 100644 --- a/Source/Engine/SAAudioAvailabilityRange.swift +++ b/Source/Engine/SAAudioAvailabilityRange.swift @@ -25,61 +25,51 @@ import Foundation -//Think of it as the grey buffer line from youtube +// Think of it as the grey buffer line from youtube public struct SAAudioAvailabilityRange { let startingNeedle: Needle let durationLoadedByNetwork: Duration let predictedDurationToLoad: Duration let isPlayable: Bool - + public var bufferingProgress: Double { - get { - return (startingNeedle + durationLoadedByNetwork) / predictedDurationToLoad - } + return (startingNeedle + durationLoadedByNetwork) / predictedDurationToLoad } - + public var startingBufferTimePositon: Double { - get { - return startingNeedle - } + return startingNeedle } - + public var totalDurationBuffered: Double { - get { - return durationLoadedByNetwork - } + return durationLoadedByNetwork } - + public var isReadyForPlaying: Bool { - get { - return isPlayable - } + return isPlayable } - + var secondsLeftToBuffer: Double { - get { - return predictedDurationToLoad - (startingNeedle + durationLoadedByNetwork) - } + return predictedDurationToLoad - (startingNeedle + durationLoadedByNetwork) } - + public func contains(_ needle: Double) -> Bool { return needle >= startingNeedle && (needle - startingNeedle) < durationLoadedByNetwork } - + public func reachedEndOfAudio(needle: Double) -> Bool { var needleAtEnd = false - - if(totalDurationBuffered > 0 && needle > 0) { + + if totalDurationBuffered > 0, needle > 0 { needleAtEnd = needle >= totalDurationBuffered - 5 } - + // if most of the audio is buffered for long audio or in short audio there isn't many seconds left to buffer it means wwe've reached the end of the audio - + let isBuffered = (bufferingProgress > 0.99 || secondsLeftToBuffer < 5) - + return isBuffered && needleAtEnd } - + public func isCompletelyBuffered() -> Bool { return startingNeedle + durationLoadedByNetwork >= predictedDurationToLoad } diff --git a/Source/Engine/SAPlayingStatus.swift b/Source/Engine/SAPlayingStatus.swift index 9392996..b1bf2fe 100644 --- a/Source/Engine/SAPlayingStatus.swift +++ b/Source/Engine/SAPlayingStatus.swift @@ -23,7 +23,6 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. - import Foundation public enum SAPlayingStatus { diff --git a/Source/LockScreenViewProtocol.swift b/Source/LockScreenViewProtocol.swift index 7b6b8b6..2362986 100644 --- a/Source/LockScreenViewProtocol.swift +++ b/Source/LockScreenViewProtocol.swift @@ -27,7 +27,7 @@ import Foundation import MediaPlayer import UIKit -public protocol LockScreenViewPresenter : AnyObject { +public protocol LockScreenViewPresenter: AnyObject { func getIsPlaying() -> Bool func handlePlay() func handlePause() @@ -37,6 +37,7 @@ public protocol LockScreenViewPresenter : AnyObject { } // MARK: - Set up lockscreen audio controls + // Documentation: https://developer.apple.com/documentation/avfoundation/media_assets_playback_and_editing/creating_a_basic_video_player_ios_and_tvos/controlling_background_audio public protocol LockScreenViewProtocol { var skipForwardSeconds: Double { get set } @@ -53,101 +54,101 @@ public extension LockScreenViewProtocol { commandCenter.skipForwardCommand.removeTarget(nil) commandCenter.changePlaybackPositionCommand.removeTarget(nil) } - + @available(iOS 10.0, tvOS 10.0, *) func setLockScreenInfo(withMediaInfo info: SALockScreenInfo?, duration: Double) { - var nowPlayingInfo:[String : Any] = [:] - + var nowPlayingInfo: [String: Any] = [:] + guard let info = info else { MPNowPlayingInfoCenter.default().nowPlayingInfo = [:] return } - + let title = info.title let artist = info.artist let albumTitle = info.albumTitle ?? artist let releaseDate = info.releaseDate - + // For some reason we need to set a duration here for the needle? nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = NSNumber(floatLiteral: duration) - + nowPlayingInfo[MPMediaItemPropertyTitle] = title nowPlayingInfo[MPMediaItemPropertyArtist] = artist nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = albumTitle - //nowPlayingInfo[MPMediaItemPropertyGenre] = //maybe later when we have it - //nowPlayingInfo[MPMediaItemPropertyIsExplicit] = //maybe later when we have it + // nowPlayingInfo[MPMediaItemPropertyGenre] = //maybe later when we have it + // nowPlayingInfo[MPMediaItemPropertyIsExplicit] = //maybe later when we have it nowPlayingInfo[MPMediaItemPropertyAlbumArtist] = artist nowPlayingInfo[MPMediaItemPropertyMediaType] = MPMediaType.podcast.rawValue nowPlayingInfo[MPMediaItemPropertyPodcastTitle] = title - nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 //because default is 1.0. If we pause audio then it keeps ticking + nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 // because default is 1.0. If we pause audio then it keeps ticking nowPlayingInfo[MPMediaItemPropertyReleaseDate] = Date(timeIntervalSince1970: TimeInterval(releaseDate)) if let artwork = info.artwork { nowPlayingInfo[MPMediaItemPropertyArtwork] = - MPMediaItemArtwork(boundsSize: artwork.size) { size in - return artwork - } + MPMediaItemArtwork(boundsSize: artwork.size) { _ in + artwork + } } else { - nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: UIImage().size) { size in - return UIImage() + nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: UIImage().size) { _ in + UIImage() } } - + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo } - + // https://stackoverflow.com/questions/36754934/update-mpremotecommandcenter-play-pause-button func setLockScreenControls(presenter: LockScreenViewPresenter) { // Get the shared MPRemoteCommandCenter let commandCenter = MPRemoteCommandCenter.shared() - + // Add handler for Play Command - commandCenter.playCommand.addTarget { [weak presenter] event in + commandCenter.playCommand.addTarget { [weak presenter] _ in guard let presenter = presenter else { return .commandFailed } - + if !presenter.getIsPlaying() { presenter.handlePlay() return .success } - + return .commandFailed } - + // Add handler for Pause Command - commandCenter.pauseCommand.addTarget { [weak presenter] event in + commandCenter.pauseCommand.addTarget { [weak presenter] _ in guard let presenter = presenter else { return .commandFailed } - + if presenter.getIsPlaying() { presenter.handlePause() return .success } - + return .commandFailed } - + commandCenter.skipBackwardCommand.preferredIntervals = [skipBackwardSeconds] as [NSNumber] commandCenter.skipForwardCommand.preferredIntervals = [skipForwardSeconds] as [NSNumber] - - commandCenter.skipBackwardCommand.addTarget { [weak presenter] event in + + commandCenter.skipBackwardCommand.addTarget { [weak presenter] _ in guard let presenter = presenter else { return .commandFailed } presenter.handleSkipBackward() return .success } - - commandCenter.skipForwardCommand.addTarget { [weak presenter] event in + + commandCenter.skipForwardCommand.addTarget { [weak presenter] _ in guard let presenter = presenter else { return .commandFailed } presenter.handleSkipForward() return .success } - + commandCenter.changePlaybackPositionCommand.addTarget { [weak presenter] event in guard let presenter = presenter else { return .commandFailed @@ -156,33 +157,33 @@ public extension LockScreenViewProtocol { presenter.handleSeek(toNeedle: Needle(positionEvent.positionTime)) return .success } - + return .commandFailed } } - + func updateLockScreenElapsedTime(needle: Double) { MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = NSNumber(value: Double(needle)) } - + func updateLockScreenPlaybackDuration(duration: Double) { MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] = NSNumber(value: duration) } - - func updateLockScreenPaused(){ + + func updateLockScreenPaused() { MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 0.0 } - - func updateLockScreenPlaying(){ + + func updateLockScreenPlaying() { MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 1.0 } - - func updateLockScreenChangePlaybackRate(speed: Float){ - if speed > 0.0{ + + func updateLockScreenChangePlaybackRate(speed: Float) { + if speed > 0.0 { MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = speed } } - + func updateLockScreenSkipIntervals() { let commandCenter = MPRemoteCommandCenter.shared() commandCenter.skipBackwardCommand.isEnabled = skipBackwardSeconds > 0 diff --git a/Source/Model/AudioDataManager.swift b/Source/Model/AudioDataManager.swift index d2f2714..2ffe87c 100644 --- a/Source/Model/AudioDataManager.swift +++ b/Source/Model/AudioDataManager.swift @@ -28,28 +28,28 @@ import Foundation protocol AudioDataManagable { var numberOfQueued: Int { get } var numberOfActive: Int { get } - + var allowCellular: Bool { get set } var downloadDirectory: FileManager.SearchPathDirectory { get } - + func setHTTPHeaderFields(_ fields: [String: String]?) - func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ()) + func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void) func setAllowCellularDownloadPreference(_ preference: Bool) func setDownloadDirectory(_ dir: FileManager.SearchPathDirectory) - + func clear() - - //Director pattern - func attach(callback: @escaping (_ id: ID, _ progress: Double)->()) - - func startStream(withRemoteURL url: AudioURL, callback: @escaping (StreamProgressPTO) -> ()) //called by throttler + + // Director pattern + func attach(callback: @escaping (_ id: ID, _ progress: Double) -> Void) + + func startStream(withRemoteURL url: AudioURL, callback: @escaping (StreamProgressPTO) -> Void) // called by throttler func pauseStream(withRemoteURL url: AudioURL) func resumeStream(withRemoteURL url: AudioURL) func seekStream(withRemoteURL url: AudioURL, toByteOffset offset: UInt64) - func deleteStream(withRemoteURL url: AudioURL) - + func deleteStream(withRemoteURL url: AudioURL) + func getPersistedUrl(withRemoteURL url: AudioURL) -> URL? - func startDownload(withRemoteURL url: AudioURL, completion: @escaping (URL, Error?) -> ()) + func startDownload(withRemoteURL url: AudioURL, completion: @escaping (URL, Error?) -> Void) func cancelDownload(withRemoteURL url: AudioURL) func deleteDownload(withLocalURL url: URL) } @@ -57,159 +57,165 @@ protocol AudioDataManagable { class AudioDataManager: AudioDataManagable { var allowCellular: Bool = true var downloadDirectory: FileManager.SearchPathDirectory = .documentDirectory - + static let shared: AudioDataManagable = AudioDataManager() - + // When we're streaming we want to stagger the size of data push up from disk to prevent the phone from freezing. We push up data of this chunk size every couple milliseconds. private let MAXIMUM_DATA_SIZE_TO_PUSH = 37744 private let TIME_IN_BETWEEN_STREAM_DATA_PUSH = 198 - - var backgroundCompletion: ()-> Void = {} // set by AppDelegate - - //This is the first case where a DAO passes a closure to a singleon that receives delegate calls from the OS. When the delegate from the OS is called, this class calls the DAO's closure. We pretty much set up a stream from the delegate call to the director (and all the items subscribed to that director) - private var globalDownloadProgressCallback: (String, Double)-> Void = {_,_ in } - + + var backgroundCompletion: () -> Void = {} // set by AppDelegate + + // This is the first case where a DAO passes a closure to a singleon that receives delegate calls from the OS. When the delegate from the OS is called, this class calls the DAO's closure. We pretty much set up a stream from the delegate call to the director (and all the items subscribed to that director) + private var globalDownloadProgressCallback: (String, Double) -> Void = { _, _ in } + private var downloadWorker: AudioDataDownloadable! private var streamWorker: AudioDataStreamable! - - private var streamingCallbacks = [(ID, (StreamProgressPTO)->())]() - + + private var streamingCallbacks = [(ID, (StreamProgressPTO) -> Void)]() + private var originalDataCountForDownloadedAudio = 0 - + var numberOfQueued: Int { return downloadWorker.numberOfQueued } - + var numberOfActive: Int { return downloadWorker.numberOfActive } - + private init() { downloadWorker = AudioDownloadWorker( allowCellular: allowCellular, progressCallback: downloadProgressListener, doneCallback: downloadDoneListener, - backgroundDownloadCallback: backgroundCompletion) - + backgroundDownloadCallback: backgroundCompletion + ) + streamWorker = AudioStreamWorker( progressCallback: streamProgressListener, - doneCallback: streamDoneListener) + doneCallback: streamDoneListener + ) } - + func clear() { streamingCallbacks = [] } - + func setHTTPHeaderFields(_ fields: [String: String]?) { streamWorker.HTTPHeaderFields = fields downloadWorker.HTTPHeaderFields = fields } - - func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ()) { + + func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void) { backgroundCompletion = completionHandler } - + func setAllowCellularDownloadPreference(_ preference: Bool) { allowCellular = preference } - + func setDownloadDirectory(_ dir: FileManager.SearchPathDirectory) { downloadDirectory = dir } - - func attach(callback: @escaping (_ id: ID, _ progress: Double)->()) { + + func attach(callback: @escaping (_ id: ID, _ progress: Double) -> Void) { globalDownloadProgressCallback = callback } } -// MARK:- Streaming +// MARK: - Streaming + extension AudioDataManager { - func startStream(withRemoteURL url: AudioURL, callback: @escaping (StreamProgressPTO) -> ()) { + func startStream(withRemoteURL url: AudioURL, callback: @escaping (StreamProgressPTO) -> Void) { if let data = FileStorage.Audio.read(url.key) { - let dto = StreamProgressDTO.init(progress: 1.0, data: data, totalBytesExpected: Int64(data.count)) + let dto = StreamProgressDTO(progress: 1.0, data: data, totalBytesExpected: Int64(data.count)) callback(StreamProgressPTO(dto: dto)) return } - - let exists = streamingCallbacks.contains { (cb: (ID, (StreamProgressPTO) -> ())) -> Bool in - return cb.0 == url.key + + let exists = streamingCallbacks.contains { (cb: (ID, (StreamProgressPTO) -> Void)) -> Bool in + cb.0 == url.key } - + if !exists { streamingCallbacks.append((url.key, callback)) } - + downloadWorker.stop(withID: url.key) { [weak self] (fetchedData: Data?, totalBytesExpected: Int64?) in self?.downloadWorker.pauseAllActive() self?.streamWorker.start(withID: url.key, withRemoteURL: url, withInitialData: fetchedData, andTotalBytesExpectedPreviously: totalBytesExpected) } } - + func pauseStream(withRemoteURL url: AudioURL) { guard streamWorker.getRunningID() == url.key else { return } streamWorker.pause(withId: url.key) } - + func resumeStream(withRemoteURL url: AudioURL) { streamWorker.resume(withId: url.key) } + func seekStream(withRemoteURL url: AudioURL, toByteOffset offset: UInt64) { streamWorker.seek(withId: url.key, withByteOffset: offset) } - + func deleteStream(withRemoteURL url: AudioURL) { streamWorker.stop(withId: url.key) - streamingCallbacks.removeAll { (cb: (ID, (StreamProgressPTO) -> ())) -> Bool in - return cb.0 == url.key + streamingCallbacks.removeAll { (cb: (ID, (StreamProgressPTO) -> Void)) -> Bool in + cb.0 == url.key } } } -// MARK:- Download +// MARK: - Download + extension AudioDataManager { func getPersistedUrl(withRemoteURL url: AudioURL) -> URL? { return FileStorage.Audio.locate(url.key) } - - func startDownload(withRemoteURL url: AudioURL, completion: @escaping (URL, Error?) -> ()) { + + func startDownload(withRemoteURL url: AudioURL, completion: @escaping (URL, Error?) -> Void) { let key = url.key - + if let savedUrl = FileStorage.Audio.locate(key), FileStorage.Audio.isStored(key) { globalDownloadProgressCallback(key, 1.0) completion(savedUrl, nil) return } - + if let currentProgress = downloadWorker.getProgressOfDownload(withID: key) { globalDownloadProgressCallback(key, currentProgress) return } - + // TODO: check if we already streaming and convert streaming to download when we have persistent play button guard streamWorker.getRunningID() != key else { Log.debug("already streaming audio, don't need to download key: \(key)") return } - + downloadWorker.start(withID: key, withRemoteUrl: url, completion: completion) } - + func cancelDownload(withRemoteURL url: AudioURL) { downloadWorker.stop(withID: url.key, callback: nil) FileStorage.Audio.delete(url.key) } - + func deleteDownload(withLocalURL url: URL) { FileStorage.delete(url) } } -// MARK:- Listeners +// MARK: - Listeners + extension AudioDataManager { private func downloadProgressListener(id: ID, progress: Double) { globalDownloadProgressCallback(id, progress) } - + private func streamProgressListener(id: ID, dto: StreamProgressDTO) { for c in streamingCallbacks { if c.0 == id { @@ -217,23 +223,21 @@ extension AudioDataManager { } } } - + private func downloadDoneListener(id: ID, error: Error?) { if error != nil { return } - + globalDownloadProgressCallback(id, 1.0) } - - private func streamDoneListener(id: ID, error: Error?) -> Bool { + + private func streamDoneListener(id _: ID, error: Error?) -> Bool { if error != nil { return false } - + downloadWorker.resumeAllActive() return false } } - - diff --git a/Source/Model/Downloading/AudioDownloadWorker.swift b/Source/Model/Downloading/AudioDownloadWorker.swift index cea6901..ae2153b 100644 --- a/Source/Model/Downloading/AudioDownloadWorker.swift +++ b/Source/Model/Downloading/AudioDownloadWorker.swift @@ -26,30 +26,30 @@ import Foundation protocol AudioDataDownloadable: AnyObject { - init(allowCellular: Bool, progressCallback: @escaping (_ id: ID, _ progress: Double)->(), doneCallback: @escaping (_ id: ID, _ error: Error?)->(), backgroundDownloadCallback: @escaping ()->()) - + init(allowCellular: Bool, progressCallback: @escaping (_ id: ID, _ progress: Double) -> Void, doneCallback: @escaping (_ id: ID, _ error: Error?) -> Void, backgroundDownloadCallback: @escaping () -> Void) + var numberOfActive: Int { get } var numberOfQueued: Int { get } - + var HTTPHeaderFields: [String: String]? { get set } - + func getProgressOfDownload(withID id: ID) -> Double? - - func start(withID id: ID, withRemoteUrl remoteUrl: URL, completion: @escaping (URL, Error?) -> ()) - func stop(withID id: ID, callback: ((_ dataSoFar: Data?, _ totalBytesExpected: Int64?) -> ())?) - func pauseAllActive() //Because of streaming - func resumeAllActive() //Because of streaming + + func start(withID id: ID, withRemoteUrl remoteUrl: URL, completion: @escaping (URL, Error?) -> Void) + func stop(withID id: ID, callback: ((_ dataSoFar: Data?, _ totalBytesExpected: Int64?) -> Void)?) + func pauseAllActive() // Because of streaming + func resumeAllActive() // Because of streaming } class AudioDownloadWorker: NSObject, AudioDataDownloadable { private let MAX_CONCURRENT_DOWNLOADS = 3 - + // Given by the AppDelegate - private let backgroundCompletion: () -> () - - private let progressHandler: (ID, Double) -> () - private let completionHandler: (ID, Error?) -> () - + private let backgroundCompletion: () -> Void + + private let progressHandler: (ID, Double) -> Void + private let completionHandler: (ID, Error?) -> Void + private let allowsCellularDownload: Bool private lazy var session: URLSession = { let config = URLSessionConfiguration.background(withIdentifier: "SwiftAudioPlayer.background_downloader_\(Date.getUTC())") @@ -59,96 +59,97 @@ class AudioDownloadWorker: NSObject, AudioDataDownloadable { config.timeoutIntervalForRequest = 30 return URLSession(configuration: config, delegate: self, delegateQueue: nil) }() - + var HTTPHeaderFields: [String: String]? - + private var activeDownloads: [ActiveDownload] = [] private var queuedDownloads = Set() - + var numberOfActive: Int { return activeDownloads.count } - + var numberOfQueued: Int { return queuedDownloads.count } - + required init(allowCellular: Bool, - progressCallback: @escaping (_ id: ID, _ progress: Double)->(), - doneCallback: @escaping (_ id: ID, _ error: Error?)->(), - backgroundDownloadCallback: @escaping ()->()) { + progressCallback: @escaping (_ id: ID, _ progress: Double) -> Void, + doneCallback: @escaping (_ id: ID, _ error: Error?) -> Void, + backgroundDownloadCallback: @escaping () -> Void) + { Log.info("init with allowCellular: \(allowCellular)") - self.progressHandler = progressCallback - self.completionHandler = doneCallback - self.backgroundCompletion = backgroundDownloadCallback - self.allowsCellularDownload = allowCellular - + progressHandler = progressCallback + completionHandler = doneCallback + backgroundCompletion = backgroundDownloadCallback + allowsCellularDownload = allowCellular + super.init() } - + func getProgressOfDownload(withID id: ID) -> Double? { return activeDownloads.filter { $0.info.id == id }.first?.progress } - - func start(withID id: ID, withRemoteUrl remoteUrl: URL, completion: @escaping (URL, Error?) -> ()) { - Log.info("startExternal paramID: \(id) activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)") + + func start(withID id: ID, withRemoteUrl remoteUrl: URL, completion: @escaping (URL, Error?) -> Void) { + Log.info("startExternal paramID: \(id) activeDownloadIDs: \((activeDownloads.map { $0.info.id }).toLog)") let temp = activeDownloads.filter { $0.info.id == id }.count guard temp == 0 else { return } - + let info = queuedDownloads.updatePreservingOldCompletionHandlers(withID: id, withRemoteUrl: remoteUrl, completion: completion) - + start(withInfo: info) } - + fileprivate func start(withInfo info: DownloadInfo) { - Log.info("paramID: \(info.id) activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)") + Log.info("paramID: \(info.id) activeDownloadIDs: \((activeDownloads.map { $0.info.id }).toLog)") let temp = activeDownloads.filter { $0.info.id == info.id }.count guard temp == 0 else { return } - + guard numberOfActive < MAX_CONCURRENT_DOWNLOADS else { _ = queuedDownloads.updatePreservingOldCompletionHandlers(withID: info.id, withRemoteUrl: info.remoteUrl) return } - + queuedDownloads.remove(info) - + var request = URLRequest(url: info.remoteUrl) HTTPHeaderFields?.forEach { request.setValue($1, forHTTPHeaderField: $0) } - + let task: URLSessionDownloadTask = session.downloadTask(with: request) task.taskDescription = info.id - + let activeTask = ActiveDownload(info: info, task: task) - + activeDownloads.append(activeTask) activeTask.task.resume() } - + func pauseAllActive() { - Log.info("activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)") + Log.info("activeDownloadIDs: \((activeDownloads.map { $0.info.id }).toLog)") for download in activeDownloads { if download.task.state == .running { download.task.suspend() } } } - + func resumeAllActive() { - Log.info("activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)") + Log.info("activeDownloadIDs: \((activeDownloads.map { $0.info.id }).toLog)") for download in activeDownloads { download.task.resume() } } - - func stop(withID id: ID, callback: ((_ dataSoFar: Data?, _ totalBytesExpected: Int64?) -> ())?) { - Log.info("paramId: \(id), activeDownloadIDs: \((activeDownloads.map { $0.info.id } ).toLog)") + + func stop(withID id: ID, callback: ((_ dataSoFar: Data?, _ totalBytesExpected: Int64?) -> Void)?) { + Log.info("paramId: \(id), activeDownloadIDs: \((activeDownloads.map { $0.info.id }).toLog)") for download in activeDownloads { - if download.info.id == id && download.task.state == .running { - download.task.cancel { (data: Data?) in + if download.info.id == id, download.task.state == .running { + download.task.cancel { (_: Data?) in callback?(nil, nil) // Could not achieve this because this resume data isn't actually the data downloaded so far but instead metadata. Not sure how to get the actual data that download task is downloading // callback?(data, download.totalBytesExpected) @@ -157,34 +158,34 @@ class AudioDownloadWorker: NSObject, AudioDataDownloadable { return } } - + queuedDownloads.remove(withMatchingId: id) callback?(nil, nil) } } extension AudioDownloadWorker: URLSessionDownloadDelegate { - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { let activeTask = activeDownloads.filter { $0.task == downloadTask }.first - + guard let task = activeTask else { Log.monitor("could not find corresponding active download task when done downloading: \(downloadTask.currentRequest?.url?.absoluteString ?? "nil url")") return } - + guard let fileType = downloadTask.response?.suggestedFilename?.pathExtension else { Log.monitor("No file type exists for file from downloading.. id: \(downloadTask.taskDescription ?? "nil") :: url: \(task.info.remoteUrl) where it suggested filename: \(downloadTask.response?.suggestedFilename ?? "nil")") return } - + let destinationUrl = FileStorage.Audio.getUrl(givenId: task.info.id, andFileExtension: fileType) Log.info("Writing download file with id: \(task.info.id) to file named: \(destinationUrl.lastPathComponent)") - + // https://stackoverflow.com/questions/20251432/cant-move-file-after-background-download-no-such-file // Apparently, the data of the temporary location get deleted outside of this function immediately, so others recommended extracting the data and writing it, this is why I'm not using DiskUtil do { _ = try FileManager.default.replaceItemAt(destinationUrl, withItemAt: location) - + Log.info("Successful write file to url: \(destinationUrl.absoluteString)") progressHandler(task.info.id, 1.0) } catch { @@ -201,27 +202,27 @@ extension AudioDownloadWorker: URLSessionDownloadDelegate { Log.monitor("Error moving file after download for task id: \(task.info.id) and error: \(error.localizedDescription)") } } - + completionHandler(task.info.id, nil) - + for handler in task.info.completionHandlers { handler(destinationUrl, nil) } - + activeDownloads = activeDownloads.filter { $0 != task } - + if let queued = queuedDownloads.popHighestRanked() { start(withInfo: queued) } } - - func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) { + + func urlSessionDidFinishEvents(forBackgroundURLSession _: URLSession) { DispatchQueue.main.async { self.backgroundCompletion() } } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { if let e = error { if let err: NSError = error as NSError? { if err.domain == NSURLErrorDomain && err.code == NSURLErrorCancelled { @@ -229,14 +230,14 @@ extension AudioDownloadWorker: URLSessionDownloadDelegate { return } } - + if let err: NSError = error as NSError? { if err.domain == NSPOSIXErrorDomain && err.code == 2 { Log.error("download error where file says it doesn't exist, this could be because of bad network") return } } - + for download in activeDownloads { if download.task == task { for handler in download.info.completionHandlers { @@ -246,68 +247,69 @@ extension AudioDownloadWorker: URLSessionDownloadDelegate { activeDownloads = activeDownloads.filter { $0.task != task } } } - + Log.monitor("\(task.currentRequest?.url?.absoluteString ?? "nil url") error: \(e.localizedDescription)") } } - - func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { - var found: Bool = false - + + func urlSession(_: URLSession, downloadTask: URLSessionDownloadTask, didWriteData _: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + var found = false + for download in activeDownloads { if download.task == downloadTask { found = true - download.progress = Double(totalBytesWritten)/Double(totalBytesExpectedToWrite) + download.progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) download.totalBytesExpected = totalBytesExpectedToWrite if download.progress != 1.0 { progressHandler(download.info.id, download.progress) } } } - + if !found { Log.monitor("could not find active download when receiving progress updates") } } } -// MARK:- Helpers -extension AudioDownloadWorker { -} +// MARK: - Helpers + +extension AudioDownloadWorker {} + +// MARK: - Helper Classes -// MARK:- Helper Classes extension AudioDownloadWorker { fileprivate struct DownloadInfo: Hashable { static func == (lhs: AudioDownloadWorker.DownloadInfo, rhs: AudioDownloadWorker.DownloadInfo) -> Bool { return lhs.id == rhs.id && lhs.remoteUrl == rhs.remoteUrl } - + let id: ID let remoteUrl: URL let rank: Int - var completionHandlers: [(URL, Error?) -> ()] - + var completionHandlers: [(URL, Error?) -> Void] + func hash(into hasher: inout Hasher) { hasher.combine(id) hasher.combine(remoteUrl) } } - + private class ActiveDownload: Hashable { static func == (lhs: AudioDownloadWorker.ActiveDownload, rhs: AudioDownloadWorker.ActiveDownload) -> Bool { return lhs.info.id == rhs.info.id } - + let info: DownloadInfo var totalBytesExpected: Int64? var progress: Double = 0.0 let task: URLSessionDownloadTask - + init(info: DownloadInfo, task: URLSessionDownloadTask) { self.info = info self.task = task } - + func hash(into hasher: inout Hasher) { hasher.combine(info.id) hasher.combine(task) @@ -317,66 +319,65 @@ extension AudioDownloadWorker { extension Set where Element == AudioDownloadWorker.DownloadInfo { mutating func popHighestRanked() -> AudioDownloadWorker.DownloadInfo? { - guard self.count > 0 else { return nil } - - var ret: AudioDownloadWorker.DownloadInfo = self.first! - + guard count > 0 else { return nil } + + var ret: AudioDownloadWorker.DownloadInfo = first! + for info in self { if info.rank > ret.rank { ret = info } } - - self.remove(ret) - + + remove(ret) + return ret } - - mutating func updatePreservingOldCompletionHandlers(withID id: ID, withRemoteUrl remoteUrl: URL, completion: ((URL, Error?) -> ())? = nil) -> AudioDownloadWorker.DownloadInfo { - + + mutating func updatePreservingOldCompletionHandlers(withID id: ID, withRemoteUrl remoteUrl: URL, completion: ((URL, Error?) -> Void)? = nil) -> AudioDownloadWorker.DownloadInfo { let rank = Date.getUTC() - - let tempHandlers: [(URL, Error?) -> ()] = completion != nil ? [completion!] : [] - - var newInfo = AudioDownloadWorker.DownloadInfo.init(id: id, remoteUrl: remoteUrl, rank: rank, completionHandlers: tempHandlers) - - if let previous = self.update(with: newInfo) { + + let tempHandlers: [(URL, Error?) -> Void] = completion != nil ? [completion!] : [] + + var newInfo = AudioDownloadWorker.DownloadInfo(id: id, remoteUrl: remoteUrl, rank: rank, completionHandlers: tempHandlers) + + if let previous = update(with: newInfo) { let prevHandlers = previous.completionHandlers let newHandlers = prevHandlers + tempHandlers - - newInfo = AudioDownloadWorker.DownloadInfo.init(id: id, remoteUrl: remoteUrl, rank: rank, completionHandlers: newHandlers) - - self.update(with: newInfo) + + newInfo = AudioDownloadWorker.DownloadInfo(id: id, remoteUrl: remoteUrl, rank: rank, completionHandlers: newHandlers) + + update(with: newInfo) } - + return newInfo } - + mutating func remove(withMatchingId id: ID) { - var toRemove: AudioDownloadWorker.DownloadInfo? = nil + var toRemove: AudioDownloadWorker.DownloadInfo? var matchCount = 0 - - for item in self.enumerated() { + + for item in enumerated() { if item.element.id == id { toRemove = item.element matchCount += 1 } } - + guard matchCount <= 1 else { Log.error("Found \(matchCount) matches of queued info with the same id of: \(id), this should have never happened.") return } - + if let removeInfo = toRemove { - self.remove(removeInfo) + remove(removeInfo) } } } extension String { var pathExtension: String? { - let cleaned = self.replacingOccurrences(of: " ", with: "_") + let cleaned = replacingOccurrences(of: " ", with: "_") let ext = URL(string: cleaned)?.pathExtension return ext == "" ? nil : ext } diff --git a/Source/Model/Downloading/FileStorage.swift b/Source/Model/Downloading/FileStorage.swift index a8d3aad..09490f3 100644 --- a/Source/Model/Downloading/FileStorage.swift +++ b/Source/Model/Downloading/FileStorage.swift @@ -30,10 +30,10 @@ import Foundation */ struct FileStorage { private init() {} - + /** Generates a URL for a file that would be saved locally. - + Note: It is not guaranteed that the file actually exists. */ static func getUrl(givenAName name: NameFile, inDirectory dir: FileManager.SearchPathDirectory) -> URL { @@ -41,46 +41,45 @@ struct FileStorage { let url = URL(fileURLWithPath: directoryPath) return url.appendingPathComponent(name) } - - static func isStored(_ url: URL) -> Bool{ + + static func isStored(_ url: URL) -> Bool { // https://stackoverflow.com/questions/42897844/swift-3-0-filemanager-fileexistsatpath-always-return-false // When determining if a file exists, we must use .path not .absolute string! return FileManager.default.fileExists(atPath: url.path) } - + static func delete(_ url: URL) { if !isStored(url) { return } - + do { try FileManager.default.removeItem(at: url) - } catch let error { + } catch { Log.error("Could not delete a file: \(error.localizedDescription)") } } } -// MARK:- Audio +// MARK: - Audio + extension FileStorage { struct Audio { private init() {} - + private static var directory: FileManager.SearchPathDirectory { - get { - return AudioDataManager.shared.downloadDirectory - } + return AudioDataManager.shared.downloadDirectory } - + static func isStored(_ id: ID) -> Bool { guard let url = locate(id)?.path else { return false } - - //FIXME: This is an unreliable API. Maybe use a map instead? + + // FIXME: This is an unreliable API. Maybe use a map instead? return FileManager.default.fileExists(atPath: url) } - + static func delete(_ id: ID) { guard let url = locate(id) else { Log.warn("trying to delete audio file that doesn't exist with id: \(id)") @@ -88,7 +87,7 @@ extension FileStorage { } return FileStorage.delete(url) } - + static func write(_ id: ID, fileExtension: String, data: Data) { do { let url = FileStorage.getUrl(givenAName: getAudioFileName(id, fileExtension: fileExtension), inDirectory: directory) @@ -97,7 +96,7 @@ extension FileStorage { Log.monitor(error.localizedDescription) } } - + static func read(_ id: ID) -> Data? { guard let url = locate(id) else { Log.debug("Trying to get data for audio file that doesn't exist: \(id)") @@ -106,7 +105,7 @@ extension FileStorage { let data = try? Data(contentsOf: url) return data } - + static func locate(_ id: ID) -> URL? { let folderUrls = FileManager.default.urls(for: directory, in: .userDomainMask) guard folderUrls.count != 0 else { return nil } @@ -121,12 +120,12 @@ extension FileStorage { } return nil } - + static func getUrl(givenId id: ID, andFileExtension fileExtension: String) -> URL { let url = FileStorage.getUrl(givenAName: getAudioFileName(id, fileExtension: fileExtension), inDirectory: directory) return url } - + private static func getAudioFileName(_ id: ID, fileExtension: String) -> NameFile { return "\(id).\(fileExtension)" } diff --git a/Source/Model/StreamProgressPTO.swift b/Source/Model/StreamProgressPTO.swift index a6c5104..8a4b134 100644 --- a/Source/Model/StreamProgressPTO.swift +++ b/Source/Model/StreamProgressPTO.swift @@ -23,21 +23,19 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. - import Foundation - struct StreamProgressPTO { let dto: StreamProgressDTO - + func getProgress() -> Double { return dto.progress } - + func getData() -> Data { return dto.data } - + func getTotalBytesExpected() -> Int64? { return dto.totalBytesExpected } diff --git a/Source/Model/Streaming/AudioStreamWorker.swift b/Source/Model/Streaming/AudioStreamWorker.swift index 2a49fdf..afa6f89 100644 --- a/Source/Model/Streaming/AudioStreamWorker.swift +++ b/Source/Model/Streaming/AudioStreamWorker.swift @@ -42,35 +42,35 @@ import Foundation */ protocol AudioDataStreamable { - //if user taps download then starts to stream - init(progressCallback: @escaping (_ id: ID, _ dto: StreamProgressDTO) -> (), doneCallback: @escaping (_ id: ID, _ error: Error?)->Bool) //Bool is should save or not - + // if user taps download then starts to stream + init(progressCallback: @escaping (_ id: ID, _ dto: StreamProgressDTO) -> Void, doneCallback: @escaping (_ id: ID, _ error: Error?) -> Bool) // Bool is should save or not + var HTTPHeaderFields: [String: String]? { get set } - + func start(withID id: ID, withRemoteURL url: URL, withInitialData data: Data?, andTotalBytesExpectedPreviously previousTotalBytesExpected: Int64?) func pause(withId id: ID) func resume(withId id: ID) - func stop(withId id: ID)//FIXME: with persistent play we should return a Data so that download can resume + func stop(withId id: ID) // FIXME: with persistent play we should return a Data so that download can resume func seek(withId id: ID, withByteOffset offset: UInt64) func getRunningID() -> ID? } -///Policy for streaming -///- only one stream at a time -///- starting a stream will cancel the previous -///- when seeking, assume that previous data is discarded -class AudioStreamWorker:NSObject, AudioDataStreamable { +/// Policy for streaming +/// - only one stream at a time +/// - starting a stream will cancel the previous +/// - when seeking, assume that previous data is discarded +class AudioStreamWorker: NSObject, AudioDataStreamable { private let TIMEOUT = 60.0 - - fileprivate let progressCallback: (_ id: ID, _ dto: StreamProgressDTO) -> () - //Will ony be called when the task object will no longer be active - //Why? So upper layer knows that current streaming activity for this ID is done - //Why? To know if we should persist the stream data assuming successful completion + + fileprivate let progressCallback: (_ id: ID, _ dto: StreamProgressDTO) -> Void + // Will ony be called when the task object will no longer be active + // Why? So upper layer knows that current streaming activity for this ID is done + // Why? To know if we should persist the stream data assuming successful completion fileprivate let doneCallback: (_ id: ID, _ error: Error?) -> Bool private var session: URLSession! - + var HTTPHeaderFields: [String: String]? - + private var id: ID? private var url: URL? private var task: URLSessionDataTask? @@ -80,51 +80,50 @@ class AudioStreamWorker:NSObject, AudioDataStreamable { fileprivate var totalBytesExpectedForCurrentStream: Int64? fileprivate var totalBytesReceived: Int64 = 0 private var corruptedBecauseOfSeek = false - - + /// Init /// /// - Parameters: /// - progressCallback: generic callback /// - doneCallback: when finished - required init(progressCallback: @escaping (_ id: ID, _ dto: StreamProgressDTO) -> (), doneCallback: @escaping (_ id: ID, _ error: Error?) -> Bool) { + required init(progressCallback: @escaping (_ id: ID, _ dto: StreamProgressDTO) -> Void, doneCallback: @escaping (_ id: ID, _ error: Error?) -> Bool) { self.progressCallback = progressCallback self.doneCallback = doneCallback super.init() - + let config = URLSessionConfiguration.background(withIdentifier: "SwiftAudioPlayer.stream") // Specifies that the phone should keep trying till it receives connection instead of dropping immediately if #available(iOS 11.0, tvOS 11.0, *) { config.waitsForConnectivity = true } - self.session = URLSession(configuration: config, delegate: self, delegateQueue: nil) //TODO: should we use ephemeral + session = URLSession(configuration: config, delegate: self, delegateQueue: nil) // TODO: should we use ephemeral } - + func start(withID id: ID, withRemoteURL url: URL, withInitialData data: Data? = nil, andTotalBytesExpectedPreviously previousTotalBytesExpected: Int64? = nil) { Log.info("selfID: \(self.id ?? "none"), paramID: \(id) initialData: \(data?.count ?? 0)") - + killPreviousTaskIfNeeded() self.id = id self.url = url - self.previousTotalBytesExpectedFromInitalData = previousTotalBytesExpected - + previousTotalBytesExpectedFromInitalData = previousTotalBytesExpected + if let data = data { var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: TIMEOUT) HTTPHeaderFields?.forEach { request.setValue($1, forHTTPHeaderField: $0) } request.addValue("bytes=\(data.count)-", forHTTPHeaderField: "Range") task = session.dataTask(with: request) task?.taskDescription = id - + initialDataBytesCount = Int64(data.count) totalBytesReceived = initialDataBytesCount totalBytesExpectedForWholeFile = previousTotalBytesExpected - - let progress = previousTotalBytesExpected != nil ? Double(initialDataBytesCount)/Double(previousTotalBytesExpected!) : 0 - + + let progress = previousTotalBytesExpected != nil ? Double(initialDataBytesCount) / Double(previousTotalBytesExpected!) : 0 + let dto = StreamProgressDTO(progress: progress, data: data, totalBytesExpected: totalBytesExpectedForWholeFile) - + progressCallback(id, dto) - + task?.resume() } else { var request = URLRequest(url: url) @@ -134,9 +133,9 @@ class AudioStreamWorker:NSObject, AudioDataStreamable { task?.resume() } } - + private func killPreviousTaskIfNeeded() { - guard let task = task else {return} + guard let task = task else { return } if task.state == .running || task.state == .suspended { task.cancel() } @@ -146,74 +145,72 @@ class AudioStreamWorker:NSObject, AudioDataStreamable { totalBytesReceived = 0 initialDataBytesCount = 0 } - + func pause(withId id: ID) { Log.info("selfID: \(self.id ?? "none"), paramID: \(id)") guard self.id == id else { Log.error("incorrect ID for command") return } - + guard let task = task else { Log.error("tried to stop a non-existent task") return } - + if task.state == .running { task.suspend() } else { Log.monitor("tried to pause a task that's already suspended") } } - + func resume(withId id: ID) { Log.info("selfID: \(self.id ?? "none"), paramID: \(id)") guard self.id == id else { Log.error("incorrect ID for command") return } - + guard let task = task else { Log.error("tried to resume a non-existent task") return } - + if task.state == .suspended { task.resume() } else { Log.monitor("tried to resume a non-suspended task") } } - + func stop(withId id: ID) { Log.info("selfID: \(self.id ?? "none"), paramID: \(id)") guard self.id == id else { Log.warn("incorrect ID for command") return } - + guard let task = task else { Log.error("tried to stop a non-existent task") return } - - + if task.state == .running || task.state == .suspended { task.cancel() self.task = nil } else { Log.error("stream_error tried to stop a task that's in state: \(task.state.rawValue)") - } } - + func seek(withId id: ID, withByteOffset offset: UInt64) { Log.info("selfID: \(self.id ?? "none"), paramID: \(id), offset: \(offset)") guard self.id == id else { Log.error("incorrect ID for command") return } - + guard let url = url else { Log.monitor("tried to seek without having URL") return @@ -221,16 +218,15 @@ class AudioStreamWorker:NSObject, AudioDataStreamable { stop(withId: id) totalBytesReceived = 0 corruptedBecauseOfSeek = true - self.progressCallback(id, StreamProgressDTO(progress: 0, data: Data(), totalBytesExpected: totalBytesExpectedForWholeFile)) - + progressCallback(id, StreamProgressDTO(progress: 0, data: Data(), totalBytesExpected: totalBytesExpectedForWholeFile)) + var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy, timeoutInterval: TIMEOUT) HTTPHeaderFields?.forEach { request.setValue($1, forHTTPHeaderField: $0) } request.addValue("bytes=\(offset)-", forHTTPHeaderField: "Range") task = session.dataTask(with: request) task?.resume() } - - + func getRunningID() -> ID? { if let task = task, task.state == .running, let id = id { return id @@ -239,106 +235,106 @@ class AudioStreamWorker:NSObject, AudioDataStreamable { } } +// MARK: - URLSessionDataDelegate -//MARK:- URLSessionDataDelegate extension AudioStreamWorker: URLSessionDataDelegate { - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { Log.debug("selfID: ", id, " dataTaskID: ", dataTask.taskDescription, " dataSize: ", data.count, " expected: ", totalBytesExpectedForWholeFile, " received: ", totalBytesReceived) guard let id = id else { - //FIXME: should be an error when done with testing phase + // FIXME: should be an error when done with testing phase Log.monitor("stream worker in weird state 9847467") return } - - guard self.task == dataTask else { - Log.error("stream_error not the same task 638283") //Probably because of seek + + guard task == dataTask else { + Log.error("stream_error not the same task 638283") // Probably because of seek return } - + guard var totalBytesExpected = totalBytesExpectedForCurrentStream else { Log.monitor("should not be called 223r2") return } - + if totalBytesExpected <= 0 { totalBytesExpected = totalBytesReceived } - + totalBytesReceived = totalBytesReceived + Int64(data.count) - let progress = Double(totalBytesReceived)/Double(totalBytesExpected) - + let progress = Double(totalBytesReceived) / Double(totalBytesExpected) + Log.debug("network streaming progress \(progress)") - self.progressCallback(id, StreamProgressDTO(progress: progress, data: data, totalBytesExpected: totalBytesExpected)) + progressCallback(id, StreamProgressDTO(progress: progress, data: data, totalBytesExpected: totalBytesExpected)) } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { + + func urlSession(_: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { Log.debug(dataTask.taskDescription, id, response.description) guard id != nil else { Log.monitor("stream worker in weird state 2049jg3") return } - - guard self.task == dataTask else { + + guard task == dataTask else { Log.error("stream_error not the same task 517253") return } - + Log.info("response length: \(response.expectedContentLength)") - - //the value will smaller if you seek. But we want to hold the OG total for duration calculations + + // the value will smaller if you seek. But we want to hold the OG total for duration calculations if !corruptedBecauseOfSeek { totalBytesExpectedForWholeFile = response.expectedContentLength + initialDataBytesCount } - + totalBytesExpectedForCurrentStream = response.expectedContentLength completionHandler(.allow) } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + + func urlSession(_: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { Log.debug(task.taskDescription, id) guard let id = id else { Log.error("stream_error stream worker in weird state 345b45") return } - + if self.task != task && self.task != nil { Log.error("stream_error not the same task 3901833") return } - + if let err: NSError = error as NSError? { if err.domain == NSURLErrorDomain && err.code == NSURLErrorCancelled { Log.info("cancelled downloading") let _ = doneCallback(id, nil) return } - + if err.domain == NSURLErrorDomain && err.code == NSURLErrorNetworkConnectionLost { Log.error("lost connection") let _ = doneCallback(id, nil) return } - + Log.monitor("\(task.currentRequest?.url?.absoluteString ?? "nil url") error: \(err.localizedDescription)") - - let _ = doneCallback(id, err) + + _ = doneCallback(id, err) return } - + let shouldSave = doneCallback(id, nil) - if shouldSave && !corruptedBecauseOfSeek { - // TODO want to save file after streaming so we do not have to download again + if shouldSave, !corruptedBecauseOfSeek { + // TODO: want to save file after streaming so we do not have to download again // guard (task.response?.suggestedFilename?.pathExtension) != nil else { // Log.monitor("Could not determine file type for file from id: \(task.taskDescription ?? "nil") and url: \(task.currentRequest?.url?.absoluteString ?? "nil")") // return // } - - // TODO no longer saving streamed files + + // TODO: no longer saving streamed files // FileStorage.Audio.write(id, fileExtension: fileType, data: data) } } - - func urlSession(_ session: URLSession, taskIsWaitingForConnectivity task: URLSessionTask) { + + func urlSession(_: URLSession, taskIsWaitingForConnectivity _: URLSessionTask) { // TODO: Notify to user that waiting for better connection } } diff --git a/Source/Model/Streaming/StreamProgressDTO.swift b/Source/Model/Streaming/StreamProgressDTO.swift index 3214031..2e62df5 100644 --- a/Source/Model/Streaming/StreamProgressDTO.swift +++ b/Source/Model/Streaming/StreamProgressDTO.swift @@ -25,7 +25,7 @@ import Foundation -//Just a helper because it got too messy +// Just a helper because it got too messy struct StreamProgressDTO { let progress: Double let data: Data diff --git a/Source/SAPlayer.swift b/Source/SAPlayer.swift index d0bc68d..a548b10 100644 --- a/Source/SAPlayer.swift +++ b/Source/SAPlayer.swift @@ -23,118 +23,105 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation public class SAPlayer { public var DEBUG_MODE: Bool = false { didSet { - if(DEBUG_MODE) { + if DEBUG_MODE { logLevel = LogLevel.EXTERNAL_DEBUG } else { logLevel = LogLevel.MONITOR } } } - - /** - Access to the player. - */ - public static let shared: SAPlayer = SAPlayer() - + private var presenter: SAPlayerPresenter! private var player: AudioEngine? - + + /** + Access the engine of the player. Engine is nil if player has not been initialized with audio. + + - Important: Changes to the engine are not safe guarded, thus unknown behaviour can arise from changing the engine. Just be wary and read [documentation of AVAudioEngine](https://developer.apple.com/documentation/avfoundation/avaudioengine) well when modifying, + */ + public private(set) var engine: AVAudioEngine! + /** - Any necessary header fields for streaming and downloading requests can be set here. - */ + Any necessary header fields for streaming and downloading requests can be set here. + */ public var HTTPHeaderFields: [String: String]? { didSet { AudioDataManager.shared.setHTTPHeaderFields(HTTPHeaderFields) } } - - /** - Access the engine of the player. Engine is nil if player has not been initialized with audio. - - - Important: Changes to the engine are not safe guarded, thus unknown behaviour can arise from changing the engine. Just be wary and read [documentation of AVAudioEngine](https://developer.apple.com/documentation/avfoundation/avaudioengine) well when modifying, - */ - public var engine: AVAudioEngine? { - get { - return player?.engine - } - } - + /** - Unique ID for the current engine. This will be nil if no audio has been initialized which means no engine exists. - */ + Unique ID for the current engine. This will be nil if no audio has been initialized which means no engine exists. + */ public var engineUID: String? { - get { - return player?.key - } + return player?.key } - + /** - Access the player node of the engine. Node is nil if player has not been initialized with audio. - - - Important: Changes to the engine and this node are not safe guarded, thus unknown behaviour can arise from changing the engine or this node. Just be wary and read [documentation of AVAudioEngine](https://developer.apple.com/documentation/avfoundation/avaudioengine) well when modifying, - */ + Access the player node of the engine. Node is nil if player has not been initialized with audio. + + - Important: Changes to the engine and this node are not safe guarded, thus unknown behaviour can arise from changing the engine or this node. Just be wary and read [documentation of AVAudioEngine](https://developer.apple.com/documentation/avfoundation/avaudioengine) well when modifying, + */ public var playerNode: AVAudioPlayerNode? { - get { - return player?.playerNode - } + return player?.playerNode } - + /** - Corresponding to the overall volume of the player. Volume's default value is 1.0 and the range of valid values is 0.0 to 1.0. Volume is nil if no audio has been initialized yet. - */ + Corresponding to the overall volume of the player. Volume's default value is 1.0 and the range of valid values is 0.0 to 1.0. Volume is nil if no audio has been initialized yet. + */ public var volume: Float? { get { return player?.playerNode.volume } - + set { guard let value = newValue else { return } - guard value >= 0.0 && value <= 1.0 else { return } - + guard value >= 0.0, value <= 1.0 else { return } + player?.playerNode.volume = value } } - + /** - Corresponding to the rate of audio playback. This rate assumes use of the default rate modifier at the first index of `audioModifiers`; if you removed that modifier than this will be nil. If no audio has been initialized then this will also be nil. - - - Note: By default this engine has added a pitch modifier node to change the pitch so that on playback rate changes of spoken word the pitch isn't shifted. - - The component description of this node is: - ```` - var componentDescription: AudioComponentDescription { - get { - var ret = AudioComponentDescription() - ret.componentType = kAudioUnitType_FormatConverter - ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther - return ret - } - } - ```` - Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details. - - For more details on pitch modifiers for playback rate changes please look at [developer.apple.com/forums/thread/6050](https://developer.apple.com/forums/thread/6050). - */ + Corresponding to the rate of audio playback. This rate assumes use of the default rate modifier at the first index of `audioModifiers`; if you removed that modifier than this will be nil. If no audio has been initialized then this will also be nil. + + - Note: By default this engine has added a pitch modifier node to change the pitch so that on playback rate changes of spoken word the pitch isn't shifted. + + The component description of this node is: + ```` + var componentDescription: AudioComponentDescription { + get { + var ret = AudioComponentDescription() + ret.componentType = kAudioUnitType_FormatConverter + ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther + return ret + } + } + ```` + Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details. + + For more details on pitch modifiers for playback rate changes please look at [developer.apple.com/forums/thread/6050](https://developer.apple.com/forums/thread/6050). + */ public var rate: Float? { get { return (audioModifiers.first as? AVAudioUnitTimePitch)?.rate } - + set { guard let value = newValue else { return } guard let node = audioModifiers.first as? AVAudioUnitTimePitch else { return } - + node.rate = value playbackRateOfAudioChanged(rate: value) } } - + /** Corresponding to the skipping forward button on the media player on the lockscreen. Default is set to 30 seconds. */ @@ -143,7 +130,7 @@ public class SAPlayer { presenter.handleScrubbingIntervalsChanged() } } - + /** Corresponding to the skipping backwards button on the media player on the lockscreen. Default is set to 15 seconds. */ @@ -152,14 +139,14 @@ public class SAPlayer { presenter.handleScrubbingIntervalsChanged() } } - + /** List of [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) audio modifiers to pass to the engine on initialization. - + - Important: To have the intended effects, the list of modifiers must be finalized before initializing the audio to be played. The modifers are added to the engine in order of the list. - + - Note: The default list already has an AVAudioUnitTimePitch node first in the list. This node is specifically set to change the rate of audio without changing the pitch of the audio (intended for changing the rate of spoken word). - + The component description of this node is: ```` var componentDescription: AudioComponentDescription { @@ -172,13 +159,13 @@ public class SAPlayer { } ```` Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details. - + For more details on pitch modifiers for playback rate changes please look at [developer.apple.com/forums/thread/6050](https://developer.apple.com/forums/thread/6050). - + To remove this default pitch modifier for playback rate changes, remove the node by calling `SAPlayer.shared.clearAudioModifiers()`. */ public var audioModifiers: [AVAudioUnit] = [] - + /** List of queued audio for playback. You can edit this list as you wish to modify the queue. */ @@ -187,83 +174,74 @@ public class SAPlayer { return presenter.audioQueue } set { - presenter.audioQueue = newValue + presenter.audioQueue = newValue } } - + /** Total duration of current audio initialized. Returns nil if no audio is initialized in player. - + - Note: If you are streaming from a source that does not have an expected size at the beginning of a stream, such as live streams, this value will be constantly updating to best known value at the time. */ public var duration: Double? { - get { - return presenter.duration - } + return presenter.duration } - + /** A textual representation of the duration of the current audio initialized. Returns nil if no audio is initialized in player. */ public var prettyDuration: String? { - get { - guard let d = duration else { return nil } - return SAPlayer.prettifyTimestamp(d) - } + guard let d = duration else { return nil } + return SAPlayer.prettifyTimestamp(d) } - + /** Elapsed playback time of the current audio initialized. Returns nil if no audio is initialized in player. */ public var elapsedTime: Double? { - get { - return presenter.needle - } + return presenter.needle } - + /** A textual representation of the elapsed playback time of the current audio initialized. Returns nil if no audio is initialized in player. */ public var prettyElapsedTime: String? { - get { - guard let e = elapsedTime else { return nil } - return SAPlayer.prettifyTimestamp(e) - } + guard let e = elapsedTime else { return nil } + return SAPlayer.prettifyTimestamp(e) } - + /** Corresponding to the media info to display on the lockscreen for the current audio. - + - Note: Setting this to nil clears the information displayed on the lockscreen media player. */ - public var mediaInfo: SALockScreenInfo? = nil - - private init() { + public var mediaInfo: SALockScreenInfo? + + public init(engine: AVAudioEngine) { + self.engine = engine presenter = SAPlayerPresenter(delegate: self) - + // https://forums.developer.apple.com/thread/5874 // https://forums.developer.apple.com/thread/6050 // AVAudioTimePitchAlgorithm.timeDomain (just in case we want it) var componentDescription: AudioComponentDescription { - get { - var ret = AudioComponentDescription() - ret.componentType = kAudioUnitType_FormatConverter - ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther - return ret - } + var ret = AudioComponentDescription() + ret.componentType = kAudioUnitType_FormatConverter + ret.componentSubType = kAudioUnitSubType_AUiPodTimeOther + return ret } - + audioModifiers.append(AVAudioUnitTimePitch(audioComponentDescription: componentDescription)) NotificationCenter.default.addObserver(self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: nil) } - + /** Clears all [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) modifiers intended to be used for realtime audio manipulation. */ public func clearAudioModifiers() { audioModifiers.removeAll() } - + /** Append an [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) modifier to the list of modifiers used for realtime audio manipulation. The modifier will be added to the end of the list. @@ -272,7 +250,7 @@ public class SAPlayer { public func addAudioModifier(_ modifer: AVAudioUnit) { audioModifiers.append(modifer) } - + /** Formats a textual representation of a given timestamp for display in hh:MM:SS format, that is hours:minutes:seconds. @@ -283,34 +261,34 @@ public class SAPlayer { let hours = Int(timestamp / 60 / 60) let minutes = Int((timestamp - Double(hours * 60 * 60)) / 60) let secondsLeft = Int(timestamp - Double(hours * 60 * 60) - Double(minutes * 60)) - + return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", secondsLeft))" } - + func getUrl(forKey key: Key) -> URL? { return presenter.getUrl(forKey: key) } - + func addUrlToMapping(url: URL) { presenter.addUrlToKeyMap(url) } - + @objc func handleInterruption(notification: Notification) { guard let userInfo = notification.userInfo, - let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, - let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { - return + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { + return } // Switch over the interruption type. switch type { - case .began: // An interruption began. Update the UI as necessary. pause() case .ended: - // An interruption ended. Resume playback, if appropriate. + // An interruption ended. Resume playback, if appropriate. guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return } let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) @@ -323,80 +301,81 @@ public class SAPlayer { default: () } - } + } } public enum SAPlayerBitrate { /// This bitrate is good for radio streams that are passing ittle amounts of audio data at a time. This will allow the player to process the audio data in a fast enough rate to not pause or get stuck playing. This rate however ends up using more CPU and is worse for your battery-life and performance of your app. case low - + /// This bitrate is good for streaming saved audio files like podcasts where most of the audio data will be received from the remote server at the beginning in a short time. This rate is more performant by using much less CPU and being better for your battery-life and app performance. case high // go for audio files being streamed. This is uses less CPU and } -//MARK: - External Player Controls -extension SAPlayer { +// MARK: - External Player Controls + +public extension SAPlayer { /** Toggles between the play and pause state of the player. If nothing is playable (aka still in buffering state or no audio is initialized) no action will be taken. Please call `startSavedAudio` or `startRemoteAudio` to set up the player with audio before this. - + - Note: If you are streaming, wait till the status from `SAPlayer.Updates.PlayingStatus` is not `.buffering`. */ - public func togglePlayAndPause() { + func togglePlayAndPause() { presenter.handleTogglePlayingAndPausing() } - + /** Attempts to play the player. If nothing is playable (aka still in buffering state or no audio is initialized) no action will be taken. Please call `startSavedAudio` or `startRemoteAudio` to set up the player with audio before this. - + - Note: If you are streaming, wait till the status from `SAPlayer.Updates.PlayingStatus` is not `.buffering`. */ - public func play() { + func play() { presenter.handlePlay() } - + /** Attempts to pause the player. If nothing is playable (aka still in buffering state or no audio is initialized) no action will be taken. Please call `startSavedAudio` or `startRemoteAudio` to set up the player with audio before this. - + - Note:If you are streaming, wait till the status from `SAPlayer.Updates.PlayingStatus` is not `.buffering`. */ - public func pause() { + func pause() { presenter.handlePause() } - + /** Attempts to skip forward in audio even if nothing playable is loaded (aka still in buffering state or no audio is initialized). The interval to which to skip forward is defined by `SAPlayer.shared.skipForwardSeconds`. - + - Note: The skipping is limited to the duration of the audio, if the intended skip is past the duration of the current audio, the skip will just go to the end. */ - public func skipForward() { + func skipForward() { presenter.handleSkipForward() } - + /** Attempts to skip backwards in audio even if nothing playable is loaded (aka still in buffering state or no audio is initialized). The interval to which to skip backwards is defined by `SAPlayer.shared.skipBackwardSeconds`. - + - Note: The skipping is limited to the playable timestamps, if the intended skip is below 0 seconds, the skip will just go to 0 seconds. */ - public func skipBackwards() { + func skipBackwards() { presenter.handleSkipBackward() } - + /** Attempts to seek/scrub through the audio even if nothing playable is loaded (aka still in buffering state or no audio is initialized). - + - Parameter seconds: The intended seconds within the audio to seek to. - + - Note: The seeking is limited to the playable timestamps, if the intended seek is below 0 seconds, the skip will just go to 0 seconds. If the intended seek is past the curation of the current audio, the seek will just go to the end. */ - public func seekTo(seconds: Double) { + func seekTo(seconds: Double) { presenter.handleSeek(toNeedle: seconds) } - + /** If using an AVAudioUnitTimePitch, it's important to notify the player that the rate at which the audio playing has changed to keep the media player in the lockscreen up to date. This is only important for playback rate changes. - + - Note: By default this engine has added a pitch modifier node to change the pitch so that on playback rate changes of spoken word the pitch isn't shifted. - + The component description of this node is: ```` var componentDescription: AudioComponentDescription { @@ -409,22 +388,22 @@ extension SAPlayer { } ```` Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details. - + For more details on pitch modifiers for playback rate changes please look at [developer.apple.com/forums/thread/6050](https://developer.apple.com/forums/thread/6050). - + - Parameter rate: The current rate at which the audio is playing. */ - public func playbackRateOfAudioChanged(rate: Float) { + func playbackRateOfAudioChanged(rate: Float) { presenter.handleAudioRateChanged(rate: rate) } - + /** Sets up player to play audio that has been saved on the device. - + - Important: If intending to use [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) audio modifiers during playback, the list of audio modifiers under `SAPlayer.shared.audioModifiers` must be finalized before calling this function. After all realtime audio manipulations within the this will be effective. - + - Note: The default list already has an AVAudioUnitTimePitch node first in the list. This node is specifically set to change the rate of audio without changing the pitch of the audio (intended for changing the rate of spoken word). - + The component description of this node is: ```` var componentDescription: AudioComponentDescription { @@ -437,31 +416,30 @@ extension SAPlayer { } ```` Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details. - + To remove this default pitch modifier for playback rate changes, remove the node by calling `SAPlayer.shared.clearAudioModifiers()`. - + - Parameter withSavedUrl: The URL of the audio saved on the device. - Parameter mediaInfo: The media information of the audio to show on the lockscreen media player (optional). */ - public func startSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo? = nil) { - + func startSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo? = nil) { // Because we support queueing, we want to clear off any existing players. // Therefore, instantiate new player every time, destroy any existing ones. // This prevents a crash where an owning engine already exists. presenter.handleClear() - + // order here matters, need to set media info before trying to play audio self.mediaInfo = mediaInfo presenter.handlePlaySavedAudio(withSavedUrl: url) } - + /** Sets up player to play audio that will be streamed from a remote location. After this is called, it will connect to the server and start to receive and process data. The player is not playable the SAAudioAvailabilityRange notifies that player is ready for playing (you can subscribe to these updates through `SAPlayer.Updates.StreamingBuffer`). You can alternatively see when the player is available to play by subscribing to `SAPlayer.Updates.PlayingStatus` and waiting for a status that isn't `.buffering`. - + - Important: If intending to use [AVAudioUnit](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/audio_engine_building_blocks/audio_enhancements) audio modifiers during playback, the list of audio modifiers under `SAPlayer.shared.audioModifiers` must be finalized before calling this function. After all realtime audio manipulations within the this will be effective. - + - Note: The default list already has an AVAudioUnitTimePitch node first in the list. This node is specifically set to change the rate of audio without changing the pitch of the audio (intended for changing the rate of spoken word). - + The component description of this node is: ```` var componentDescription: AudioComponentDescription { @@ -474,106 +452,105 @@ extension SAPlayer { } ```` Please look at [forums.developer.apple.com/thread/5874](https://forums.developer.apple.com/thread/5874) and [forums.developer.apple.com/thread/6050](https://forums.developer.apple.com/thread/6050) for more details. - + To remove this default pitch modifier for playback rate changes, remove the node by calling `SAPlayer.shared.clearAudioModifiers()`. - + - Note: Subscribe to `SAPlayer.Updates.StreamingBuffer` to see updates in streaming progress. - + - Parameter withRemoteUrl: The URL of the remote audio. - Parameter bitrate: The bitrate of the streamed audio. By default the bitrate is set to high for streaming saved audio files. If you want to stream radios then you should use the `low` bitrate option. - Parameter mediaInfo: The media information of the audio to show on the lockscreen media player (optional). */ - public func startRemoteAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate = .high, mediaInfo: SALockScreenInfo? = nil) { - + func startRemoteAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate = .high, mediaInfo: SALockScreenInfo? = nil) { // Because we support queueing, we want to clear off any existing players. // Therefore, instantiate new player every time, destroy any existing ones. // This prevents a crash where an owning engine already exists. presenter.handleClear() - + // order here matters, need to set media info before trying to play audio self.mediaInfo = mediaInfo presenter.handlePlayStreamedAudio(withRemoteUrl: url, bitrate: bitrate) } - + /** Stops any streaming in progress. */ - public func stopStreamingRemoteAudio() { + func stopStreamingRemoteAudio() { presenter.handleStopStreamingAudio() } - + /** Queues remote audio to be played next. The URLs in the queue can be both remote or on disk but once the queued audio starts playing it will start buffering and loading then. This means no guarantee for a 'gapless' playback where there might be several moments in between one audio ending and another starting due to buffering remote audio. - + - Parameter withRemoteUrl: The URL of the remote audio. - Parameter bitrate: The bitrate of the streamed audio. By default the bitrate is set to high for streaming saved audio files. If you want to stream radios then you should use the `low` bitrate option. - Parameter mediaInfo: The media information of the audio to show on the lockscreen media player (optional). */ - public func queueRemoteAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate = .high, mediaInfo: SALockScreenInfo? = nil) { + func queueRemoteAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate = .high, mediaInfo: SALockScreenInfo? = nil) { presenter.handleQueueStreamedAudio(withRemoteUrl: url, mediaInfo: mediaInfo, bitrate: bitrate) } - + /** Queues saved audio to be played next. The URLs in the queue can be both remote or on disk but once the queued audio starts playing it will start buffering and loading then. This means no guarantee for a 'gapless' playback where there might be several moments in between one audio ending and another starting due to buffering remote audio. - + - Parameter withSavedUrl: The URL of the audio saved on the device. - Parameter mediaInfo: The media information of the audio to show on the lockscreen media player (optional). */ - public func queueSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo? = nil) { + func queueSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo? = nil) { presenter.handleQueueSavedAudio(withSavedUrl: url, mediaInfo: mediaInfo) } - + /** Remove the first queued audio if one exists. Receive the first URL removed back. - + - Returns the URL of the removed audio. */ - public func removeFirstQueuedAudio() -> URL? { + func removeFirstQueuedAudio() -> URL? { guard audioQueued.count != 0 else { return nil } return presenter.handleRemoveFirstQueuedItem() } - + /** Clear the list of queued audio. - + - Returns the list of removed audio URLs */ - public func clearAllQueuedAudio() -> [URL] { + func clearAllQueuedAudio() -> [URL] { return presenter.handleClearQueued() } - + /** Resets the player to the state before initializing audio and setting media info. */ - public func clear() { + func clear() { presenter.handleClear() } } +// MARK: - Internal implementation of delegate -//MARK: - Internal implementation of delegate extension SAPlayer: SAPlayerDelegate { internal func startAudioDownloaded(withSavedUrl url: AudioURL) { - player = AudioDiskEngine(withSavedUrl: url, delegate: presenter) + player = AudioDiskEngine(withSavedUrl: url, delegate: presenter, engine: engine) } - + internal func startAudioStreamed(withRemoteUrl url: AudioURL, bitrate: SAPlayerBitrate) { - player = AudioStreamEngine(withRemoteUrl: url, delegate: presenter, bitrate: bitrate) + player = AudioStreamEngine(withRemoteUrl: url, delegate: presenter, bitrate: bitrate, engine: engine) } - + internal func clearEngine() { player?.pause() player?.invalidate() player = nil Log.info("cleared engine") } - + internal func playEngine() { becomeDeviceAudioPlayer() player?.play() } - - //Start taking control as the device's player + + // Start taking control as the device's player private func becomeDeviceAudioPlayer() { do { if #available(iOS 11.0, tvOS 11.0, *) { @@ -586,19 +563,18 @@ extension SAPlayer: SAPlayerDelegate { Log.monitor("Problem setting up AVAudioSession to play in:: \(error.localizedDescription)") } } - + internal func pauseEngine() { player?.pause() } - + internal func seekEngine(toNeedle needle: Needle) { let seekToNeedle = needle < 0 ? 0 : needle player?.seek(toNeedle: seekToNeedle) } } - // Helper function inserted by Swift 4.2 migrator. -fileprivate func convertFromAVAudioSessionMode(_ input: AVAudioSession.Mode) -> String { - return input.rawValue +private func convertFromAVAudioSessionMode(_ input: AVAudioSession.Mode) -> String { + return input.rawValue } diff --git a/Source/SAPlayerDelegate.swift b/Source/SAPlayerDelegate.swift index 119814e..4ba7bea 100644 --- a/Source/SAPlayerDelegate.swift +++ b/Source/SAPlayerDelegate.swift @@ -23,18 +23,20 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation +import AVFAudio import CoreMedia +import Foundation protocol SAPlayerDelegate: AnyObject, LockScreenViewProtocol { var mediaInfo: SALockScreenInfo? { get set } var skipForwardSeconds: Double { get set } var skipBackwardSeconds: Double { get set } - + var audioModifiers: [AVAudioUnit] { get } + func startAudioDownloaded(withSavedUrl url: AudioURL) func startAudioStreamed(withRemoteUrl url: AudioURL, bitrate: SAPlayerBitrate) func clearEngine() func playEngine() func pauseEngine() - func seekEngine(toNeedle needle: Needle) //TODO ensure that engine cleans up out of bounds + func seekEngine(toNeedle needle: Needle) // TODO: ensure that engine cleans up out of bounds } diff --git a/Source/SAPlayerDownloader.swift b/Source/SAPlayerDownloader.swift index bec161d..59fc9fb 100644 --- a/Source/SAPlayerDownloader.swift +++ b/Source/SAPlayerDownloader.swift @@ -25,82 +25,82 @@ import Foundation -extension SAPlayer { +public extension SAPlayer { /** Actions relating to downloading remote audio to the device for offline playback. - + - Note: All saved urls generated from downloaded audio corresponds to a specific remote url. Thus, can be queryed if original remote url is known. - + - Important: Please ensure that you have passed in the background download completion handler in the AppDelegate with `setBackgroundCompletionHandler` to allow for downloading audio while app is in the background. */ - public struct Downloader { + enum Downloader { /** Download audio from a remote url. Will save the audio on the device for playback later. - + Save the saved url of the downloaded audio for future playback or query for the saved url with the same remote url in the future. - + - Note: It's recommended to have a weak reference to a class that uses this function - + - Note: Subscribe to `SAPlayer.Updates.AudioDownloading` to see updates in downloading progress. - + - Parameter url: The remote url to download audio from. - Parameter completion: Completion handler that will return once the download is successful and complete. - Parameter savedUrl: The url of where the audio was saved locally on the device. Will receive once download has completed. */ - public static func downloadAudio(withRemoteUrl url: URL, completion: @escaping (_ savedUrl: URL, _ error: Error?) -> ()) { - SAPlayer.shared.addUrlToMapping(url: url) + public static func downloadAudio(on player: SAPlayer, withRemoteUrl url: URL, completion: @escaping (_ savedUrl: URL, _ error: Error?) -> Void) { + player.addUrlToMapping(url: url) AudioDataManager.shared.startDownload(withRemoteURL: url, completion: completion) } - + /** Cancel downloading audio from a specific remote url if actively downloading. If download has not started yet, it will remove from the list of future downloads queued. - + - Parameter url: The remote url corresponding to the active download you want to cancel. */ public static func cancelDownload(withRemoteUrl url: URL) { AudioDataManager.shared.cancelDownload(withRemoteURL: url) } - + /** Delete downloaded audio file from device at url. - + - Note: This will delete any file saved on device at the local url. This, however, is intended to use for audio files. - + - Parameter url: The url of the audio to delete from the device. */ public static func deleteDownloaded(withSavedUrl url: URL) { AudioDataManager.shared.deleteDownload(withLocalURL: url) } - + /** Check if audio at remote url is downloaded on device. - + - Parameter url: The remote url corresponding to the audio file you want to see if downloaded. - Returns: Whether of not file at remote url is downloaded on device. */ public static func isDownloaded(withRemoteUrl url: URL) -> Bool { return AudioDataManager.shared.getPersistedUrl(withRemoteURL: url) != nil } - + /** Get url of audio file downloaded from remote url onto on device if it exists. - + - Parameter url: The remote url corresponding to the audio file you want the device url of. - Returns: Url of audio file on device if it exists. */ public static func getSavedUrl(forRemoteUrl url: URL) -> URL? { return AudioDataManager.shared.getPersistedUrl(withRemoteURL: url) } - + /** Pass along the completion handler from `AppDelegate` to ensure downloading continues while app is in background. - + - Parameter completionHandler: The completion hander from `AppDelegate` to use for app in the background downloads. */ - public static func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> ()) { + public static func setBackgroundCompletionHandler(_ completionHandler: @escaping () -> Void) { AudioDataManager.shared.setBackgroundCompletionHandler(completionHandler) } - + /** Whether downloading audio on cellular data is allowed. By default this is set to `true`. */ @@ -109,7 +109,7 @@ extension SAPlayer { AudioDataManager.shared.setAllowCellularDownloadPreference(allowUsingCellularData) } } - + /** EXPERIMENTAL! */ diff --git a/Source/SAPlayerFeatures.swift b/Source/SAPlayerFeatures.swift index 4637988..d809827 100644 --- a/Source/SAPlayerFeatures.swift +++ b/Source/SAPlayerFeatures.swift @@ -5,44 +5,41 @@ // Created by Tanha Kabir on 3/10/21. // -import Foundation import AVFoundation +import Foundation -extension SAPlayer { +public extension SAPlayer { /** Special features for audio manipulation. These are examples of manipulations you can do with the player outside of this library. This is just an aggregation of community contibuted ones. - + - Note: These features assume default state of the player and `audioModifiers` meaning some expect the first audio modifier to be the default `AVAudioUnitTimePitch` that comes with the SAPlayer. */ - public struct Features { - + struct Features { /** Feature to skip silences in spoken word audio. The player will speed up the rate of audio playback when silence is detected. - + - Important: The first audio modifier must be the default `AVAudioUnitTimePitch` that comes with the SAPlayer for this feature to work. */ public struct SkipSilences { - static var enabled: Bool = false static var originalRate: Float = 1.0 - + /** Enable feature to skip silences in spoken word audio. The player will speed up the rate of audio playback when silence is detected. This can be called at any point of audio playback. - + - Precondition: The first audio modifier must be the default `AVAudioUnitTimePitch` that comes with the SAPlayer for this feature to work. - Important: If you want to change the rate of the overall player while having skip silences on, please use `SAPlayer.Features.SkipSilences.setRateSafely()` to properly set the rate of the player. Any rate changes to the player will be ignored while using Skip Silences otherwise. */ - public static func enable() -> Bool { - guard let engine = SAPlayer.shared.engine else { return false } - + public static func enable(on player: SAPlayer) -> Bool { + guard let engine = player.engine else { return false } + Log.info("enabling skip silences feature") enabled = true - originalRate = SAPlayer.shared.rate ?? 1.0 + originalRate = player.rate ?? 1.0 let format = engine.mainMixerNode.outputFormat(forBus: 0) - - + // look at documentation here to get an understanding of what is happening here: https://www.raywenderlich.com/5154-avaudioengine-tutorial-for-ios-getting-started#toc-anchor-005 - engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, when in + engine.mainMixerNode.installTap(onBus: 0, bufferSize: 1024, format: format) { buffer, _ in guard let channelData = buffer.floatChannelData else { return } @@ -59,41 +56,41 @@ extension SAPlayer { let meterLevel = self.scaledPower(power: avgPower) Log.debug("meterLevel: \(meterLevel)") if meterLevel < 0.6 { // below 0.6 decibels is below audible audio - SAPlayer.shared.rate = originalRate + 0.5 - Log.debug("speed up rate to \(String(describing: SAPlayer.shared.rate))") + player.rate = originalRate + 0.5 + Log.debug("speed up rate to \(String(describing: player.rate))") } else { - SAPlayer.shared.rate = originalRate - Log.debug("slow down rate to \(String(describing: SAPlayer.shared.rate))") + player.rate = originalRate + Log.debug("slow down rate to \(String(describing: player.rate))") } } - + return true } - + /** Disable feature to skip silences in spoken word audio. The player will speed up the rate of audio playback when silence is detected. This can be called at any point of audio playback. - + - Precondition: The first audio modifier must be the default `AVAudioUnitTimePitch` that comes with the SAPlayer for this feature to work. */ - public static func disable() -> Bool { - guard let engine = SAPlayer.shared.engine else { return false } + public static func disable(on player: SAPlayer) -> Bool { + guard let engine = player.engine else { return false } Log.info("disabling skip silences feature") engine.mainMixerNode.removeTap(onBus: 0) - SAPlayer.shared.rate = originalRate + player.rate = originalRate enabled = false return true } - + /** Use this function to set the overall rate of the player for when skip silences is on. This ensures that the overall rate will be what is set through this function even as skip silences is on; if this function is not used then any changes asked of from the overall player while skip silences is on won't be recorded! - + - Important: The first audio modifier must be the default `AVAudioUnitTimePitch` that comes with the SAPlayer for this feature to work. */ - public static func setRateSafely(_ rate: Float) { + public static func setRateSafely(_ rate: Float, on player: SAPlayer) { originalRate = rate - SAPlayer.shared.rate = rate + player.rate = rate } - + private static func scaledPower(power: Float) -> Float { guard power.isFinite else { return 0.0 } let minDb: Float = -80.0 @@ -106,62 +103,61 @@ extension SAPlayer { } } } - + /** Feature to pause the player after a delay. This will happen regardless of if another audio clip has started. */ - public struct SleepTimer { + public enum SleepTimer { static var timer: Timer? - + /** Enable feature to pause the player after a delay. This will happen regardless of if another audio clip has started. - + - Parameter afterDelay: The number of seconds to wait before pausing the audio */ - public static func enable(afterDelay delay: Double) { + public static func enable(afterDelay delay: Double, on player: SAPlayer) { timer = Timer.scheduledTimer(withTimeInterval: delay, repeats: false, block: { _ in - SAPlayer.shared.pause() + player.pause() }) } - + /** - Disable feature to pause the player after a delay. + Disable feature to pause the player after a delay. */ public static func disable() { timer?.invalidate() } } - + /** Feature to play the current playing audio on repeat until feature is disabled. */ - public struct Loop { + public enum Loop { static var enabled: Bool = false static var playingStatusId: UInt? - + /** Enable feature to play the current playing audio on loop. This will continue until the feature is disabled. And this feature works for both remote and saved audio. */ - public static func enable() { + public static func enable(on player: SAPlayer) { enabled = true - + guard playingStatusId == nil else { return } - - playingStatusId = SAPlayer.Updates.PlayingStatus.subscribe({ (status) in - if status == .ended && enabled { - SAPlayer.shared.seekTo(seconds: 0.0) - SAPlayer.shared.play() + + playingStatusId = SAPlayer.Updates.PlayingStatus.subscribe { status in + if status == .ended, enabled { + player.seekTo(seconds: 0.0) + player.play() } - }) + } } - + /** Disable feature playing audio on loop. */ public static func disable() { enabled = false } - } } } diff --git a/Source/SAPlayerHelpers.swift b/Source/SAPlayerHelpers.swift index c17ef12..baed03b 100644 --- a/Source/SAPlayerHelpers.swift +++ b/Source/SAPlayerHelpers.swift @@ -40,7 +40,7 @@ public struct SALockScreenInfo { var albumTitle: String? var artwork: UIImage? var releaseDate: UTC - + public init(title: String, artist: String, albumTitle: String?, artwork: UIImage?, releaseDate: UTC) { self.title = title self.artist = artist @@ -50,7 +50,6 @@ public struct SALockScreenInfo { } } - /** Use to add audio to be queued for playback. */ @@ -59,10 +58,10 @@ public struct SAAudioQueueItem { public var url: URL public var mediaInfo: SALockScreenInfo? public var bitrate: SAPlayerBitrate - + /** Use to add audio to be queued for playback. - + - Parameter loc: If the URL for the file is remote or saved on device. - Parameter url: URL of audio to be queued - Parameter mediaInfo: Relevant lockscreen media info for the queued audio. @@ -74,7 +73,7 @@ public struct SAAudioQueueItem { self.mediaInfo = mediaInfo self.bitrate = bitrate } - + /** Where the queued audio is sourced. Remote to be streamed or locally saved on device. */ diff --git a/Source/SAPlayerPresenter.swift b/Source/SAPlayerPresenter.swift index fc28957..a364a19 100644 --- a/Source/SAPlayerPresenter.swift +++ b/Source/SAPlayerPresenter.swift @@ -23,140 +23,140 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import Foundation import AVFoundation +import Foundation import MediaPlayer class SAPlayerPresenter { weak var delegate: SAPlayerDelegate? - var shouldPlayImmediately = false //for auto-play - + var shouldPlayImmediately = false // for auto-play + var needle: Needle? var duration: Duration? - + private var key: String? private var isPlaying: SAPlayingStatus = .buffering - + private var urlKeyMap: [Key: URL] = [:] - + var durationRef: UInt = 0 var needleRef: UInt = 0 var playingStatusRef: UInt = 0 var audioQueue: [SAAudioQueueItem] = [] - + init(delegate: SAPlayerDelegate?) { self.delegate = delegate - durationRef = AudioClockDirector.shared.attachToChangesInDuration(closure: { [weak self] (duration) in + durationRef = AudioClockDirector.shared.attachToChangesInDuration(closure: { [weak self] duration in guard let self = self else { throw DirectorError.closureIsDead } - + self.delegate?.updateLockScreenPlaybackDuration(duration: duration) self.duration = duration - + self.delegate?.setLockScreenInfo(withMediaInfo: self.delegate?.mediaInfo, duration: duration) }) - - needleRef = AudioClockDirector.shared.attachToChangesInNeedle(closure: { [weak self] (needle) in + + needleRef = AudioClockDirector.shared.attachToChangesInNeedle(closure: { [weak self] needle in guard let self = self else { throw DirectorError.closureIsDead } - + self.needle = needle self.delegate?.updateLockScreenElapsedTime(needle: needle) }) - - playingStatusRef = AudioClockDirector.shared.attachToChangesInPlayingStatus(closure: { [weak self] (isPlaying) in + + playingStatusRef = AudioClockDirector.shared.attachToChangesInPlayingStatus(closure: { [weak self] isPlaying in guard let self = self else { throw DirectorError.closureIsDead } - - if(isPlaying == .paused && self.shouldPlayImmediately) { + + if isPlaying == .paused, self.shouldPlayImmediately { self.shouldPlayImmediately = false self.handlePlay() } - + // solves bug nil == owningEngine || GetEngine() == owningEngine where too many // ended statuses were notified to cause 2 engines to be initialized and causes an error. - // TODO don't need guard + // TODO: don't need guard guard isPlaying != self.isPlaying else { return } self.isPlaying = isPlaying - - if(self.isPlaying == .ended) { + + if self.isPlaying == .ended { self.playNextAudioIfExists() } }) } - + func getUrl(forKey key: Key) -> URL? { return urlKeyMap[key] } - + func addUrlToKeyMap(_ url: URL) { urlKeyMap[url.key] = url } - + func handleClear() { delegate?.clearEngine() AudioClockDirector.shared.resetCache() - + needle = nil duration = nil key = nil delegate?.mediaInfo = nil delegate?.clearLockScreenInfo() } - + func handlePlaySavedAudio(withSavedUrl url: URL) { resetCacheForNewAudio(url: url) delegate?.setLockScreenControls(presenter: self) delegate?.startAudioDownloaded(withSavedUrl: url) } - + func handlePlayStreamedAudio(withRemoteUrl url: URL, bitrate: SAPlayerBitrate) { resetCacheForNewAudio(url: url) delegate?.setLockScreenControls(presenter: self) delegate?.startAudioStreamed(withRemoteUrl: url, bitrate: bitrate) } - + private func resetCacheForNewAudio(url: URL) { - self.key = url.key + key = url.key urlKeyMap[url.key] = url - + AudioClockDirector.shared.setKey(url.key) AudioClockDirector.shared.resetCache() } - + func handleQueueStreamedAudio(withRemoteUrl url: URL, mediaInfo: SALockScreenInfo?, bitrate: SAPlayerBitrate) { audioQueue.append(SAAudioQueueItem(loc: .remote, url: url, mediaInfo: mediaInfo, bitrate: bitrate)) } - + func handleQueueSavedAudio(withSavedUrl url: URL, mediaInfo: SALockScreenInfo?) { audioQueue.append(SAAudioQueueItem(loc: .saved, url: url, mediaInfo: mediaInfo)) } - + func handleRemoveFirstQueuedItem() -> URL? { guard audioQueue.count != 0 else { return nil } - + return audioQueue.remove(at: 0).url } - + func handleClearQueued() -> [URL] { guard audioQueue.count != 0 else { return [] } - + let urls = audioQueue.map { item in - return item.url + item.url } - + audioQueue = [] return urls } - + func handleStopStreamingAudio() { delegate?.clearEngine() AudioClockDirector.shared.resetCache() } } -//MARK:- Used by outside world including: +// MARK: - Used by outside world including: + // SPP, lock screen, directors extension SAPlayerPresenter { - func handleTogglePlayingAndPausing() { if isPlaying == .playing { handlePause() @@ -168,34 +168,34 @@ extension SAPlayerPresenter { func handleAudioRateChanged(rate: Float) { delegate?.updateLockScreenChangePlaybackRate(speed: rate) } - + func handleScrubbingIntervalsChanged() { delegate?.updateLockScreenSkipIntervals() } } -//MARK:- For lock screen -extension SAPlayerPresenter : LockScreenViewPresenter { - +// MARK: - For lock screen + +extension SAPlayerPresenter: LockScreenViewPresenter { func getIsPlaying() -> Bool { return isPlaying == .playing } func handlePlay() { delegate?.playEngine() - self.delegate?.updateLockScreenPlaying() + delegate?.updateLockScreenPlaying() } func handlePause() { delegate?.pauseEngine() - self.delegate?.updateLockScreenPaused() + delegate?.updateLockScreenPaused() } func handleSkipBackward() { guard let backward = delegate?.skipForwardSeconds else { return } handleSeek(toNeedle: (needle ?? 0) - backward) } - + func handleSkipForward() { guard let forward = delegate?.skipForwardSeconds else { return } handleSeek(toNeedle: (needle ?? 0) + forward) @@ -206,14 +206,20 @@ extension SAPlayerPresenter : LockScreenViewPresenter { } } -//MARK:- AVAudioEngineDelegate +// MARK: - AVAudioEngineDelegate + extension SAPlayerPresenter: AudioEngineDelegate { + var audioModifiers: [AVAudioUnit] { + delegate?.audioModifiers ?? [] + } + func didError() { Log.monitor("We should have handled engine error") } } -//MARK:- Autoplay +// MARK: - Autoplay + extension SAPlayerPresenter { func playNextAudioIfExists() { Log.info("looking foor next audio in queue to play") @@ -225,20 +231,18 @@ extension SAPlayerPresenter { Log.info("getting ready to play \(nextAudioURL)") AudioQueueDirector.shared.changeInQueue(url: nextAudioURL.url) - + handleClear() - + delegate?.mediaInfo = nextAudioURL.mediaInfo - + switch nextAudioURL.loc { case .remote: handlePlayStreamedAudio(withRemoteUrl: nextAudioURL.url, bitrate: nextAudioURL.bitrate) - break case .saved: handlePlaySavedAudio(withSavedUrl: nextAudioURL.url) - break } - + shouldPlayImmediately = true } } diff --git a/Source/SAPlayerUpdateSubscription.swift b/Source/SAPlayerUpdateSubscription.swift index 57bdd74..94167ce 100644 --- a/Source/SAPlayerUpdateSubscription.swift +++ b/Source/SAPlayerUpdateSubscription.swift @@ -25,240 +25,157 @@ import Foundation -extension SAPlayer { - +public extension SAPlayer { /** Receive updates for changing values from the player, such as the duration, elapsed time of playing audio, download progress, and etc. */ - public struct Updates { - + enum Updates { /** Updates to changes in the timestamp/elapsed time of the current initialized audio. Aka, where the scrubber's pointer of the audio should be at. */ - public struct ElapsedTime { - + public enum ElapsedTime { /** Subscribe to updates in elapsed time of the playing audio. Aka, the current timestamp of the audio. - - - Note: It's recommended to have a weak reference to a class that uses this function - - - Parameter closure: The closure that will receive the updates of the changes in time. - - Parameter url: The corresponding remote URL for the updated playing time. - - Parameter timePosition: The current time within the audio that is playing. - - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. - */ - @available(*, deprecated, message: "Use subscribe without the url in the closure for current audio updates") - public static func subscribe(_ closure: @escaping (_ url: URL, _ timePosition: Double) -> ()) -> UInt { - return AudioClockDirector.shared.attachToChangesInNeedle(closure: { (key, needle) in - guard let url = SAPlayer.shared.getUrl(forKey: key) else { return } - closure(url, needle) - }) - } - - /** - Subscribe to updates in elapsed time of the playing audio. Aka, the current timestamp of the audio. - + - Note: It's recommended to have a weak reference to a class that uses this function - + - Parameter closure: The closure that will receive the updates of the changes in time. - Parameter timePosition: The current time within the audio that is playing. - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. */ - public static func subscribe(_ closure: @escaping (_ timePosition: Double) -> ()) -> UInt { + public static func subscribe(_ closure: @escaping (_ timePosition: Double) -> Void) -> UInt { AudioClockDirector.shared.attachToChangesInNeedle(closure: closure) } - + /** Stop recieving updates of changes in elapsed time of audio. - + - Parameter id: The closure with this id will stop receiving updates. */ public static func unsubscribe(_ id: UInt) { AudioClockDirector.shared.detachFromChangesInNeedle(withID: id) } } - + /** Updates to changes in the duration of the current initialized audio. Especially helpful for audio that is being streamed and can change with more data. - + - Note: If you are streaming from a source that does not have an expected size at the beginning of a stream, such as live streams, duration will be constantly updating to best known value at the time (which is the seconds buffered currently and not necessarily the actual total duration of audio). */ - public struct Duration { - - /** - Subscribe to updates to changes in duration of the current audio initialized. - - - Note: If you are streaming from a source that does not have an expected size at the beginning of a stream, such as live streams, duration will be constantly updating to best known value at the time (which is the seconds buffered currently and not necessarily the actual total duration of audio). - - - Note: It's recommended to have a weak reference to a class that uses this function - - - Parameter closure: The closure that will receive the updates of the changes in duration. - - Parameter url: The corresponding remote URL for the updated duration. - - Parameter duration: The duration of the current initialized audio. - - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. - */ - @available(*, deprecated, message: "Use subscribe without the url in the closure for current audio updates") - public static func subscribe(_ closure: @escaping (_ url: URL, _ duration: Double) -> ()) -> UInt { - return AudioClockDirector.shared.attachToChangesInDuration(closure: { (key, duration) in - guard let url = SAPlayer.shared.getUrl(forKey: key) else { return } - closure(url, duration) - }) - } - + public enum Duration { /** Subscribe to updates to changes in duration of the current audio initialized. - + - Note: If you are streaming from a source that does not have an expected size at the beginning of a stream, such as live streams, duration will be constantly updating to best known value at the time (which is the seconds buffered currently and not necessarily the actual total duration of audio). - + - Note: It's recommended to have a weak reference to a class that uses this function - + - Parameter closure: The closure that will receive the updates of the changes in duration. - Parameter duration: The duration of the current initialized audio. - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. */ - public static func subscribe(_ closure: @escaping (_ duration: Double) -> ()) -> UInt { + public static func subscribe(_ closure: @escaping (_ duration: Double) -> Void) -> UInt { return AudioClockDirector.shared.attachToChangesInDuration(closure: closure) } - + /** Stop recieving updates of changes in duration of the current initialized audio. - + - Parameter id: The closure with this id will stop receiving updates. */ public static func unsubscribe(_ id: UInt) { AudioClockDirector.shared.detachFromChangesInDuration(withID: id) } } - + /** Updates to changes in the playing/paused status of the player. */ - public struct PlayingStatus { - + public enum PlayingStatus { /** Subscribe to updates to changes in the playing/paused status of audio. - - - Note: It's recommended to have a weak reference to a class that uses this function - - - Parameter closure: The closure that will receive the updates of the changes in duration. - - Parameter url: The corresponding remote URL for the updated duration. - - Parameter playingStatus: Whether the player is playing audio or paused. - - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. - */ - @available(*, deprecated, message: "Use subscribe without the url in the closure for current audio updates") - public static func subscribe(_ closure: @escaping (_ url: URL, _ playingStatus: SAPlayingStatus) -> ()) -> UInt { - return AudioClockDirector.shared.attachToChangesInPlayingStatus(closure: { (key, isPlaying) in - guard let url = SAPlayer.shared.getUrl(forKey: key) else { return } - closure(url, isPlaying) - }) - } - - /** - Subscribe to updates to changes in the playing/paused status of audio. - + - Note: It's recommended to have a weak reference to a class that uses this function - + - Parameter closure: The closure that will receive the updates of the changes in duration. - Parameter playingStatus: Whether the player is playing audio or paused. - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. */ - public static func subscribe(_ closure: @escaping (_ playingStatus: SAPlayingStatus) -> ()) -> UInt { + public static func subscribe(_ closure: @escaping (_ playingStatus: SAPlayingStatus) -> Void) -> UInt { return AudioClockDirector.shared.attachToChangesInPlayingStatus(closure: closure) } - + /** Stop recieving updates of changes in the playing/paused status of audio. - + - Parameter id: The closure with this id will stop receiving updates. */ public static func unsubscribe(_ id: UInt) { AudioClockDirector.shared.detachFromChangesInPlayingStatus(withID: id) } } - + /** Updates to changes in the progress of downloading audio for streaming. Information about range of audio available and if the audio is playable. Look at `SAAudioAvailabilityRange` for more information. */ - public struct StreamingBuffer { - + public enum StreamingBuffer { /** Subscribe to updates to changes in the progress of downloading audio for streaming. Information about range of audio available and if the audio is playable. Look at SAAudioAvailabilityRange for more information. For progress of downloading audio that saves to the phone for playback later, look at AudioDownloading instead. - - - Note: For live streams that don't have an expected audio length from the beginning of the stream; the duration is constantly changing and equal to the total seconds buffered from the SAAudioAvailabilityRange. - - - Note: It's recommended to have a weak reference to a class that uses this function - - - Parameter closure: The closure that will receive the updates of the changes in duration. - - Parameter url: The corresponding remote URL for the updated streaming progress. - - Parameter buffer: Availabity of audio that has been downloaded to play. - - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. - */ - @available(*, deprecated, message: "Use subscribe without the url in the closure for current audio updates") - public static func subscribe(_ closure: @escaping (_ url: URL, _ buffer: SAAudioAvailabilityRange) -> ()) -> UInt { - return AudioClockDirector.shared.attachToChangesInBufferedRange(closure: { (key, buffer) in - guard let url = SAPlayer.shared.getUrl(forKey: key) else { return } - closure(url, buffer) - }) - } - - /** - Subscribe to updates to changes in the progress of downloading audio for streaming. Information about range of audio available and if the audio is playable. Look at SAAudioAvailabilityRange for more information. For progress of downloading audio that saves to the phone for playback later, look at AudioDownloading instead. - + - Note: For live streams that don't have an expected audio length from the beginning of the stream; the duration is constantly changing and equal to the total seconds buffered from the SAAudioAvailabilityRange. - + - Note: It's recommended to have a weak reference to a class that uses this function - + - Parameter closure: The closure that will receive the updates of the changes in duration. - Parameter buffer: Availabity of audio that has been downloaded to play. - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. */ - public static func subscribe(_ closure: @escaping (_ buffer: SAAudioAvailabilityRange) -> ()) -> UInt { + public static func subscribe(_ closure: @escaping (_ buffer: SAAudioAvailabilityRange) -> Void) -> UInt { return AudioClockDirector.shared.attachToChangesInBufferedRange(closure: closure) } - + /** Stop recieving updates of changes in streaming progress. - + - Parameter id: The closure with this id will stop receiving updates. */ public static func unsubscribe(_ id: UInt) { AudioClockDirector.shared.detachFromChangesInBufferedRange(withID: id) } } - + /** Updates to changes in the progress of downloading audio in the background. This does not correspond to progress in streaming downloads, look at StreamingBuffer for streaming progress. */ - public struct AudioDownloading { - + public enum AudioDownloading { /** Subscribe to updates to changes in the progress of downloading audio. This does not correspond to progress in streaming downloads, look at StreamingBuffer for streaming progress. - + - Note: It's recommended to have a weak reference to a class that uses this function - + - Parameter closure: The closure that will receive the updates of the changes in duration. - Parameter url: The corresponding remote URL for the updated download progress. - Parameter progress: Value from 0.0 to 1.0 indicating progress of download. - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. */ - public static func subscribe(_ closure: @escaping (_ url: URL, _ progress: Double) -> ()) -> UInt { - return DownloadProgressDirector.shared.attach(closure: { (key, progress) in - guard let url = SAPlayer.shared.getUrl(forKey: key) else { return } + public static func subscribe(on player: SAPlayer, _ closure: @escaping (_ url: URL, _ progress: Double) -> Void) -> UInt { + return DownloadProgressDirector.shared.attach(closure: { key, progress in + guard let url = player.getUrl(forKey: key) else { return } closure(url, progress) }) } - + /** Stop recieving updates of changes in download progress. - + - Parameter id: The closure with this id will stop receiving updates. */ public static func unsubscribe(_ id: UInt) { DownloadProgressDirector.shared.detach(withID: id) } } - - public struct AudioQueue { + + public enum AudioQueue { /** Subscribe to updates to changes in the progress of your audio queue. When streaming audio playback completes and continues onto the next track, the closure is invoked. @@ -267,7 +184,7 @@ extension SAPlayer { - Parameter url: The corresponding remote URL for the forthcoming audio file. - Returns: the id for the subscription in the case you would like to unsubscribe to updates for the closure. */ - public static func subscribe(_ closure: @escaping (_ newUrl: URL) -> ()) -> UInt { + public static func subscribe(_ closure: @escaping (_ newUrl: URL) -> Void) -> UInt { return AudioQueueDirector.shared.attach(closure: closure) } @@ -281,4 +198,3 @@ extension SAPlayer { } } } - diff --git a/Source/Util/Constants.swift b/Source/Util/Constants.swift index c8415cd..0daf8f8 100644 --- a/Source/Util/Constants.swift +++ b/Source/Util/Constants.swift @@ -32,6 +32,6 @@ typealias AudioURL = URL typealias IsPlaying = Bool typealias ID = String -typealias NameFile = String //Should have last path component (.mp3) +typealias NameFile = String // Should have last path component (.mp3) let DEBOUNCING_BUFFER_TIME: Double = 1.0 diff --git a/Source/Util/Data.swift b/Source/Util/Data.swift index 892f727..2ead7bb 100644 --- a/Source/Util/Data.swift +++ b/Source/Util/Data.swift @@ -39,7 +39,7 @@ extension Data { return try body(unsafePointer) } } - + mutating func accessMutableBytes(_ body: (UnsafeMutablePointer) throws -> R) rethrows -> R { return try withUnsafeMutableBytes { (rawBufferPointer: UnsafeMutableRawBufferPointer) -> R in let unsafeMutableBufferPointer = rawBufferPointer.bindMemory(to: UInt8.self) diff --git a/Source/Util/Date.swift b/Source/Util/Date.swift index 4a660bd..b0c0619 100644 --- a/Source/Util/Date.swift +++ b/Source/Util/Date.swift @@ -28,20 +28,20 @@ import Foundation extension Date { /** Finds the 64-bit representation of UTC. rand() uses UTC as a seed, so using the raw UTC should be sufficient for our case. - + - Returns: A 64-bit representation of time. */ static func getUTC64() -> UInt { - //"On 32-bit platforms, UInt is the same size as UInt32, and on 64-bit platforms, UInt is the same size as UInt64." - + // "On 32-bit platforms, UInt is the same size as UInt32, and on 64-bit platforms, UInt is the same size as UInt64." + if #available(iOS 11.0, *) { return UInt(Date().timeIntervalSince1970.bitPattern) } else { - let time = Date().timeIntervalSince1970.bitPattern & 0xFFFFFFFF; + let time = Date().timeIntervalSince1970.bitPattern & 0xFFFF_FFFF return UInt(time) } } - + /** - Returns: UTC in seconds. */ diff --git a/Source/Util/DirectorThreadSafeClosures.swift b/Source/Util/DirectorThreadSafeClosures.swift index 5358ef1..ded9d52 100644 --- a/Source/Util/DirectorThreadSafeClosures.swift +++ b/Source/Util/DirectorThreadSafeClosures.swift @@ -25,26 +25,23 @@ import Foundation - /** P for payload */ -class DirectorThreadSafeClosures

{ +class DirectorThreadSafeClosures

{ typealias TypeClosure = (P) throws -> Void - private var queue: DispatchQueue = DispatchQueue(label: "SwiftAudioPlayer.thread_safe_map", attributes: .concurrent) + private var queue: DispatchQueue = .init(label: "SwiftAudioPlayer.thread_safe_map", attributes: .concurrent) private var closures: [UInt: TypeClosure] = [:] - private var cache: P? = nil - + private var cache: P? + var count: Int { - get { - return closures.count - } + return closures.count } - + func resetCache() { cache = nil } - + func broadcast(payload: P) { queue.sync { self.cache = payload @@ -58,13 +55,13 @@ class DirectorThreadSafeClosures

{ } } } - - //UInt is actually 64-bits on modern devices + + // UInt is actually 64-bits on modern devices func attach(closure: @escaping TypeClosure) -> UInt { let id: UInt = Date.getUTC64() - - //The director may not yet have the status yet. We should only call the closure if we have it - //Let the caller know the immediate value. If it's dead already then stop + + // The director may not yet have the status yet. We should only call the closure if we have it + // Let the caller know the immediate value. If it's dead already then stop if let val = cache { do { try closure(val) @@ -72,30 +69,30 @@ class DirectorThreadSafeClosures

{ return id } } - - //Replace what's in the map with the new closure + + // Replace what's in the map with the new closure helperInsert(withKey: id, closure: closure) - + return id } - + func detach(id: UInt) { helperRemove(withKey: id) } - + func clear() { queue.async(flags: .barrier) { self.closures.removeAll() self.cache = nil } } - + private func helperRemove(withKey key: UInt) { queue.async(flags: .barrier) { self.closures[key] = nil } } - + private func helperInsert(withKey key: UInt, closure: @escaping TypeClosure) { queue.async(flags: .barrier) { self.closures[key] = closure diff --git a/Source/Util/DirectorThreadSafeClosuresDeprecated.swift b/Source/Util/DirectorThreadSafeClosuresDeprecated.swift index dd02d3a..4dfeceb 100644 --- a/Source/Util/DirectorThreadSafeClosuresDeprecated.swift +++ b/Source/Util/DirectorThreadSafeClosuresDeprecated.swift @@ -32,18 +32,16 @@ enum DirectorError: Error { /** P for payload */ -class DirectorThreadSafeClosuresDeprecated

{ +class DirectorThreadSafeClosuresDeprecated

{ typealias TypeClosure = (Key, P) throws -> Void - private var queue: DispatchQueue = DispatchQueue(label: "SwiftAudioPlayer.thread_safe_map", attributes: .concurrent) + private var queue: DispatchQueue = .init(label: "SwiftAudioPlayer.thread_safe_map", attributes: .concurrent) private var closures: [UInt: TypeClosure] = [:] private var cache: [Key: P] = [:] - + var count: Int { - get { - return closures.count - } + return closures.count } - + func broadcast(key: Key, payload: P) { queue.sync { self.cache[key] = payload @@ -57,13 +55,13 @@ class DirectorThreadSafeClosuresDeprecated

{ } } } - - //UInt is actually 64-bits on modern devices + + // UInt is actually 64-bits on modern devices func attach(closure: @escaping TypeClosure) -> UInt { let id: UInt = Date.getUTC64() - - //The director may not yet have the status yet. We should only call the closure if we have it - //Let the caller know the immediate value. If it's dead already then stop + + // The director may not yet have the status yet. We should only call the closure if we have it + // Let the caller know the immediate value. If it's dead already then stop for (key, val) in cache { do { try closure(key, val) @@ -71,30 +69,30 @@ class DirectorThreadSafeClosuresDeprecated

{ return id } } - - //Replace what's in the map with the new closure + + // Replace what's in the map with the new closure helperInsert(withKey: id, closure: closure) - + return id } - + func detach(id: UInt) { helperRemove(withKey: id) } - + func clear() { queue.async(flags: .barrier) { self.closures.removeAll() self.cache.removeAll() } } - + private func helperRemove(withKey key: UInt) { queue.async(flags: .barrier) { self.closures[key] = nil } } - + private func helperInsert(withKey key: UInt, closure: @escaping TypeClosure) { queue.async(flags: .barrier) { self.closures[key] = closure diff --git a/Source/Util/Log.swift b/Source/Util/Log.swift index 0938001..4e4f2ae 100644 --- a/Source/Util/Log.swift +++ b/Source/Util/Log.swift @@ -21,22 +21,22 @@ enum LogLevel: Int { } // Specify which types of log messages to display. Default level is set to WARN, which means Log will print any log messages of type only WARN, ERROR, MONITOR, and TEST. To print DEBUG and INFO logs, set the level to a lower value. -var logLevel: LogLevel = LogLevel.MONITOR +var logLevel: LogLevel = .MONITOR class Log { private init() {} - + // Used for OSLog private static let SUBSYSTEM: String = "com.SwiftAudioPlayer" - + /** Used for when you're doing tests. Testing log should be removed before commiting - + How to use: Log.test("this is my message") Output: 13:51:38.487 TEST ❇️❇️❇️❇️ in InputNameViewController.swift:addContainerToVC():77:: this is test - + To change the log level, visit the LogLevel enum - + - Parameter logMessage: The message to show - Parameter classPath: automatically generated based on the class that called this function - Parameter functionName: automatically generated based on the function that called this function @@ -49,15 +49,15 @@ class Log { os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } } - + /** Used when something unexpected happen, such as going out of bounds in an array. Errors are typically guarded for. - + How to use: Log.error("this is error") Output: 13:51:38.487 ERROR 🛑🛑🛑🛑 in InputNameViewController.swift:addContainerToVC():76:: this is error - + To change the log level, visit the LogLevel enum - + - Parameter logMessage: The message to show - Parameter classPath: automatically generated based on the class that called this function - Parameter functionName: automatically generated based on the function that called this function @@ -69,21 +69,21 @@ class Log { let log = OSLog(subsystem: SUBSYSTEM, category: "ERROR 🛑🛑🛑🛑") os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } - + if logLevel.rawValue <= LogLevel.EXTERNAL_DEBUG.rawValue { let log = OSLog(subsystem: SUBSYSTEM, category: "WARNING") os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } } - + /** Used when something catastrophic just happened. Like app about to crash, app state is inconsistent, or possible data corruption. - + How to use: Log.error("this is error") Output: 13:51:38.487 MONITOR 🔥🔥🔥🔥 in InputNameViewController.swift:addContainerToVC():76:: data in corrupted state! - + To change the log level, visit the LogLevel enum - + - Parameter logMessage: The message to show - Parameter classPath: automatically generated based on the class that called this function - Parameter functionName: automatically generated based on the function that called this function @@ -96,15 +96,15 @@ class Log { os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } } - + /** Used when something went wrong, but the app can still function. - + How to use: Log.warn("this is warn") Output: 13:51:38.487 WARN ⚠️⚠️⚠️⚠️ in InputNameViewController.swift:addContainerToVC():75:: this is warn - + To change the log level, visit the LogLevel enum - + - Parameter logMessage: The message to show - Parameter classPath: automatically generated based on the class that called this function - Parameter functionName: automatically generated based on the function that called this function @@ -116,21 +116,21 @@ class Log { let log = OSLog(subsystem: SUBSYSTEM, category: "WARN ⚠️⚠️⚠️⚠️") os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } - + if logLevel.rawValue <= LogLevel.EXTERNAL_DEBUG.rawValue { let log = OSLog(subsystem: SUBSYSTEM, category: "DEBUG") os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } } - + /** Used when you want to show information like username or question asked. - + How to use: Log.info("this is info") Output: 13:51:38.486 INFO 🖤🖤🖤🖤 in InputNameViewController.swift:addContainerToVC():74:: this is info - + To change the log level, visit the LogLevel enum - + - Parameter logMessage: The message to show - Parameter classPath: automatically generated based on the class that called this function - Parameter functionName: automatically generated based on the function that called this function @@ -143,15 +143,15 @@ class Log { os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } } - + /** Used for when you're rebugging and you want to follow what's happening. - + How to use: Log.debug("this is debug") Output: 13:51:38.485 DEBUG 🐝🐝🐝🐝 in InputNameViewController.swift:addContainerToVC():73:: this is debug - + To change the log level, visit the LogLevel enum - + - Parameter logMessage: The message to show - Parameter classPath: automatically generated based on the class that called this function - Parameter functionName: automatically generated based on the function that called this function @@ -164,26 +164,25 @@ class Log { os_log("%@:%@:%d:: %@", log: log, fileName, functionName, lineNumber, "\(logMessage)") } } - - } -// MARK:- Helpers for Log class -fileprivate struct URLUtil { +// MARK: - Helpers for Log class + +private enum URLUtil { static func getNameFromStringPath(_ stringPath: String) -> String { - //URL sees that "+" is a " " + // URL sees that "+" is a " " let stringPath = stringPath.replacingOccurrences(of: " ", with: "+") let url = URL(string: stringPath) return url!.lastPathComponent } - + static func getNameFromURL(_ url: URL) -> String { return url.lastPathComponent } } -extension Date { - fileprivate func timeStamp() -> String { +private extension Date { + func timeStamp() -> String { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss.SSS" return formatter.string(from: self) @@ -191,8 +190,8 @@ extension Date { } extension Array where Element == Any? { - var toLog: String { - var strs:[String] = [] + var toLog: String { + var strs: [String] = [] for element in self { strs.append("\(element ?? "nil")") } diff --git a/Source/Util/URL.swift b/Source/Util/URL.swift index e2bc7f6..4fa3603 100644 --- a/Source/Util/URL.swift +++ b/Source/Util/URL.swift @@ -9,22 +9,17 @@ import Foundation extension URL { var key: String { - get { - return "audio_\(self.absoluteString.hashed)" - } + return "audio_\(absoluteString.hashed)" } } - -fileprivate extension String { +private extension String { var hashed: UInt64 { - get { - var result = UInt64 (8742) - let buf = [UInt8](self.utf8) - for b in buf { - result = 127 * (result & 0x00ffffffffffffff) + UInt64(b) - } - return result + var result = UInt64(8742) + let buf = [UInt8](utf8) + for b in buf { + result = 127 * (result & 0x00FF_FFFF_FFFF_FFFF) + UInt64(b) } + return result } }