From 178ba14516901764c5819673b4a2c03b03061688 Mon Sep 17 00:00:00 2001 From: Daniel Freiling Date: Mon, 21 Aug 2023 18:15:39 +0200 Subject: [PATCH] feat: add iOS AudioPlayer from Nota fork --- ios/Assets/.gitkeep | 0 ios/Classes/FlutterAudioPlayerPlugin.swift | 19 + ios/Classes/Reachability.swift | 406 ++++++++++++++ .../event/AudioItemEventProducer.swift | 108 ++++ ios/Classes/event/EventProducer.swift | 34 ++ ios/Classes/event/NetworkEventProducer.swift | 118 ++++ ios/Classes/event/PlayerEventProducer.swift | 310 +++++++++++ .../QualityAdjustmentEventProducer.swift | 153 +++++ ios/Classes/event/RetryEventProducer.swift | 106 ++++ ios/Classes/event/SeekEventProducer.swift | 92 +++ ios/Classes/item/AudioItem.swift | 229 ++++++++ ios/Classes/item/AudioItemQueue.swift | 232 ++++++++ ios/Classes/item/SeekOperation.swift | 30 + ios/Classes/player/AudioPlayer.swift | 525 ++++++++++++++++++ .../player/AudioPlayerBufferingStrategy.swift | 23 + ios/Classes/player/AudioPlayerDelegate.swift | 80 +++ ios/Classes/player/AudioPlayerMode.swift | 35 ++ ios/Classes/player/AudioPlayerState.swift | 101 ++++ .../AudioPlayer+AudioItemEvent.swift | 18 + .../extensions/AudioPlayer+Control.swift | 221 ++++++++ .../extensions/AudioPlayer+CurrentItem.swift | 86 +++ .../extensions/AudioPlayer+NetworkEvent.swift | 57 ++ .../extensions/AudioPlayer+PlayerEvent.swift | 189 +++++++ .../extensions/AudioPlayer+Preload.swift | 98 ++++ .../AudioPlayer+QualityAdjustmentEvent.swift | 62 +++ .../player/extensions/AudioPlayer+Queue.swift | 112 ++++ .../AudioPlayer+RemoteControl.swift | 248 +++++++++ .../extensions/AudioPlayer+RetryEvent.swift | 28 + .../extensions/AudioPlayer+SeekEvent.swift | 29 + ios/Classes/utils/BackgroundHandler.swift | 110 ++++ .../utils/CMTime+TimeIntervalValue.swift | 30 + ios/Classes/utils/Debug.swift | 39 ++ .../MPNowPlayingInfoCenter+AudioItem.swift | 49 ++ ios/Classes/utils/URL+Offline.swift | 18 + ios/README.md | 9 + 35 files changed, 4004 insertions(+) create mode 100644 ios/Assets/.gitkeep create mode 100644 ios/Classes/FlutterAudioPlayerPlugin.swift create mode 100644 ios/Classes/Reachability.swift create mode 100644 ios/Classes/event/AudioItemEventProducer.swift create mode 100644 ios/Classes/event/EventProducer.swift create mode 100644 ios/Classes/event/NetworkEventProducer.swift create mode 100644 ios/Classes/event/PlayerEventProducer.swift create mode 100644 ios/Classes/event/QualityAdjustmentEventProducer.swift create mode 100644 ios/Classes/event/RetryEventProducer.swift create mode 100644 ios/Classes/event/SeekEventProducer.swift create mode 100644 ios/Classes/item/AudioItem.swift create mode 100644 ios/Classes/item/AudioItemQueue.swift create mode 100644 ios/Classes/item/SeekOperation.swift create mode 100644 ios/Classes/player/AudioPlayer.swift create mode 100644 ios/Classes/player/AudioPlayerBufferingStrategy.swift create mode 100644 ios/Classes/player/AudioPlayerDelegate.swift create mode 100644 ios/Classes/player/AudioPlayerMode.swift create mode 100644 ios/Classes/player/AudioPlayerState.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+AudioItemEvent.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+Control.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+CurrentItem.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+NetworkEvent.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+PlayerEvent.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+Preload.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+QualityAdjustmentEvent.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+Queue.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+RemoteControl.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+RetryEvent.swift create mode 100644 ios/Classes/player/extensions/AudioPlayer+SeekEvent.swift create mode 100644 ios/Classes/utils/BackgroundHandler.swift create mode 100644 ios/Classes/utils/CMTime+TimeIntervalValue.swift create mode 100644 ios/Classes/utils/Debug.swift create mode 100644 ios/Classes/utils/MPNowPlayingInfoCenter+AudioItem.swift create mode 100644 ios/Classes/utils/URL+Offline.swift create mode 100644 ios/README.md diff --git a/ios/Assets/.gitkeep b/ios/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/ios/Classes/FlutterAudioPlayerPlugin.swift b/ios/Classes/FlutterAudioPlayerPlugin.swift new file mode 100644 index 0000000..1c0955d --- /dev/null +++ b/ios/Classes/FlutterAudioPlayerPlugin.swift @@ -0,0 +1,19 @@ +import Flutter +import UIKit + +public class FlutterAudioPlayerPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flutter_audio_player", binaryMessenger: registrar.messenger()) + let instance = FlutterAudioPlayerPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformVersion": + result("iOS " + UIDevice.current.systemVersion) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/ios/Classes/Reachability.swift b/ios/Classes/Reachability.swift new file mode 100644 index 0000000..e387396 --- /dev/null +++ b/ios/Classes/Reachability.swift @@ -0,0 +1,406 @@ +/* +Copyright (c) 2014, Ashley Mills +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation +and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +*/ + +import SystemConfiguration +import Foundation + +public enum ReachabilityError: Error { + case failedToCreateWithAddress(sockaddr, Int32) + case failedToCreateWithHostname(String, Int32) + case unableToSetCallback(Int32) + case unableToSetDispatchQueue(Int32) + case unableToGetFlags(Int32) +} + +@available(*, unavailable, renamed: "Notification.Name.reachabilityChanged") +public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") + +public extension Notification.Name { + static let reachabilityChanged = Notification.Name("reachabilityChanged") +} + +public class Reachability { + + public typealias NetworkReachable = (Reachability) -> () + public typealias NetworkUnreachable = (Reachability) -> () + + @available(*, unavailable, renamed: "Connection") + public enum NetworkStatus: CustomStringConvertible { + case notReachable, reachableViaWiFi, reachableViaWWAN + public var description: String { + switch self { + case .reachableViaWWAN: return "Cellular" + case .reachableViaWiFi: return "WiFi" + case .notReachable: return "No Connection" + } + } + } + + public enum Connection: CustomStringConvertible { + @available(*, deprecated, renamed: "unavailable") + case none + case unavailable, wifi, cellular + public var description: String { + switch self { + case .cellular: return "Cellular" + case .wifi: return "WiFi" + case .unavailable: return "No Connection" + case .none: return "unavailable" + } + } + } + + public var whenReachable: NetworkReachable? + public var whenUnreachable: NetworkUnreachable? + + @available(*, deprecated, renamed: "allowsCellularConnection") + public let reachableOnWWAN: Bool = true + + /// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`) + public var allowsCellularConnection: Bool + + // The notification center on which "reachability changed" events are being posted + public var notificationCenter: NotificationCenter = NotificationCenter.default + + @available(*, deprecated, renamed: "connection.description") + public var currentReachabilityString: String { + return "\(connection)" + } + + @available(*, unavailable, renamed: "connection") + public var currentReachabilityStatus: Connection { + return connection + } + + public var connection: Connection { + if flags == nil { + try? setReachabilityFlags() + } + + switch flags?.connection { + case .unavailable?, nil: return .unavailable + case .none?: return .unavailable + case .cellular?: return allowsCellularConnection ? .cellular : .unavailable + case .wifi?: return .wifi + } + } + + fileprivate var isRunningOnDevice: Bool = { + #if targetEnvironment(simulator) + return false + #else + return true + #endif + }() + + fileprivate(set) var notifierRunning = false + fileprivate let reachabilityRef: SCNetworkReachability + fileprivate let reachabilitySerialQueue: DispatchQueue + fileprivate let notificationQueue: DispatchQueue? + fileprivate(set) var flags: SCNetworkReachabilityFlags? { + didSet { + guard flags != oldValue else { return } + notifyReachabilityChanged() + } + } + + required public init(reachabilityRef: SCNetworkReachability, + queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) { + self.allowsCellularConnection = true + self.reachabilityRef = reachabilityRef + self.reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability", qos: queueQoS, target: targetQueue) + self.notificationQueue = notificationQueue + } + + public convenience init(hostname: String, + queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) throws { + guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { + throw ReachabilityError.failedToCreateWithHostname(hostname, SCError()) + } + self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + } + + public convenience init(queueQoS: DispatchQoS = .default, + targetQueue: DispatchQueue? = nil, + notificationQueue: DispatchQueue? = .main) throws { + var zeroAddress = sockaddr() + zeroAddress.sa_len = UInt8(MemoryLayout.size) + zeroAddress.sa_family = sa_family_t(AF_INET) + + guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { + throw ReachabilityError.failedToCreateWithAddress(zeroAddress, SCError()) + } + + self.init(reachabilityRef: ref, queueQoS: queueQoS, targetQueue: targetQueue, notificationQueue: notificationQueue) + } + + deinit { + stopNotifier() + } +} + +public extension Reachability { + + // MARK: - *** Notifier methods *** + func startNotifier() throws { + guard !notifierRunning else { return } + + let callback: SCNetworkReachabilityCallBack = { (reachability, flags, info) in + guard let info = info else { return } + + // `weakifiedReachability` is guaranteed to exist by virtue of our + // retain/release callbacks which we provided to the `SCNetworkReachabilityContext`. + let weakifiedReachability = Unmanaged.fromOpaque(info).takeUnretainedValue() + + // The weak `reachability` _may_ no longer exist if the `Reachability` + // object has since been deallocated but a callback was already in flight. + weakifiedReachability.reachability?.flags = flags + } + + let weakifiedReachability = ReachabilityWeakifier(reachability: self) + let opaqueWeakifiedReachability = Unmanaged.passUnretained(weakifiedReachability).toOpaque() + + var context = SCNetworkReachabilityContext( + version: 0, + info: UnsafeMutableRawPointer(opaqueWeakifiedReachability), + retain: { (info: UnsafeRawPointer) -> UnsafeRawPointer in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + _ = unmanagedWeakifiedReachability.retain() + return UnsafeRawPointer(unmanagedWeakifiedReachability.toOpaque()) + }, + release: { (info: UnsafeRawPointer) -> Void in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + unmanagedWeakifiedReachability.release() + }, + copyDescription: { (info: UnsafeRawPointer) -> Unmanaged in + let unmanagedWeakifiedReachability = Unmanaged.fromOpaque(info) + let weakifiedReachability = unmanagedWeakifiedReachability.takeUnretainedValue() + let description = weakifiedReachability.reachability?.description ?? "nil" + return Unmanaged.passRetained(description as CFString) + } + ) + + if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) { + stopNotifier() + throw ReachabilityError.unableToSetCallback(SCError()) + } + + if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) { + stopNotifier() + throw ReachabilityError.unableToSetDispatchQueue(SCError()) + } + + // Perform an initial check + try setReachabilityFlags() + + notifierRunning = true + } + + func stopNotifier() { + defer { notifierRunning = false } + + SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil) + SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil) + } + + // MARK: - *** Connection test methods *** + @available(*, deprecated, message: "Please use `connection != .none`") + var isReachable: Bool { + return connection != .unavailable + } + + @available(*, deprecated, message: "Please use `connection == .cellular`") + var isReachableViaWWAN: Bool { + // Check we're not on the simulator, we're REACHABLE and check we're on WWAN + return connection == .cellular + } + + @available(*, deprecated, message: "Please use `connection == .wifi`") + var isReachableViaWiFi: Bool { + return connection == .wifi + } + + var description: String { + return flags?.description ?? "unavailable flags" + } +} + +fileprivate extension Reachability { + + func setReachabilityFlags() throws { + try reachabilitySerialQueue.sync { [unowned self] in + var flags = SCNetworkReachabilityFlags() + if !SCNetworkReachabilityGetFlags(self.reachabilityRef, &flags) { + self.stopNotifier() + throw ReachabilityError.unableToGetFlags(SCError()) + } + + self.flags = flags + } + } + + + func notifyReachabilityChanged() { + let notify = { [weak self] in + guard let self = self else { return } + self.connection != .unavailable ? self.whenReachable?(self) : self.whenUnreachable?(self) + self.notificationCenter.post(name: .reachabilityChanged, object: self) + } + + // notify on the configured `notificationQueue`, or the caller's (i.e. `reachabilitySerialQueue`) + notificationQueue?.async(execute: notify) ?? notify() + } +} + +extension SCNetworkReachabilityFlags { + + typealias Connection = Reachability.Connection + + var connection: Connection { + guard isReachableFlagSet else { return .unavailable } + + // If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi + #if targetEnvironment(simulator) + return .wifi + #else + var connection = Connection.unavailable + + if !isConnectionRequiredFlagSet { + connection = .wifi + } + + if isConnectionOnTrafficOrDemandFlagSet { + if !isInterventionRequiredFlagSet { + connection = .wifi + } + } + + if isOnWWANFlagSet { + connection = .cellular + } + + return connection + #endif + } + + var isOnWWANFlagSet: Bool { + #if os(iOS) + return contains(.isWWAN) + #else + return false + #endif + } + var isReachableFlagSet: Bool { + return contains(.reachable) + } + var isConnectionRequiredFlagSet: Bool { + return contains(.connectionRequired) + } + var isInterventionRequiredFlagSet: Bool { + return contains(.interventionRequired) + } + var isConnectionOnTrafficFlagSet: Bool { + return contains(.connectionOnTraffic) + } + var isConnectionOnDemandFlagSet: Bool { + return contains(.connectionOnDemand) + } + var isConnectionOnTrafficOrDemandFlagSet: Bool { + return !intersection([.connectionOnTraffic, .connectionOnDemand]).isEmpty + } + var isTransientConnectionFlagSet: Bool { + return contains(.transientConnection) + } + var isLocalAddressFlagSet: Bool { + return contains(.isLocalAddress) + } + var isDirectFlagSet: Bool { + return contains(.isDirect) + } + var isConnectionRequiredAndTransientFlagSet: Bool { + return intersection([.connectionRequired, .transientConnection]) == [.connectionRequired, .transientConnection] + } + + var description: String { + let W = isOnWWANFlagSet ? "W" : "-" + let R = isReachableFlagSet ? "R" : "-" + let c = isConnectionRequiredFlagSet ? "c" : "-" + let t = isTransientConnectionFlagSet ? "t" : "-" + let i = isInterventionRequiredFlagSet ? "i" : "-" + let C = isConnectionOnTrafficFlagSet ? "C" : "-" + let D = isConnectionOnDemandFlagSet ? "D" : "-" + let l = isLocalAddressFlagSet ? "l" : "-" + let d = isDirectFlagSet ? "d" : "-" + + return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)" + } +} + +/** + `ReachabilityWeakifier` weakly wraps the `Reachability` class + in order to break retain cycles when interacting with CoreFoundation. + + CoreFoundation callbacks expect a pair of retain/release whenever an + opaque `info` parameter is provided. These callbacks exist to guard + against memory management race conditions when invoking the callbacks. + + #### Race Condition + + If we passed `SCNetworkReachabilitySetCallback` a direct reference to our + `Reachability` class without also providing corresponding retain/release + callbacks, then a race condition can lead to crashes when: + - `Reachability` is deallocated on thread X + - A `SCNetworkReachability` callback(s) is already in flight on thread Y + + #### Retain Cycle + + If we pass `Reachability` to CoreFoundtion while also providing retain/ + release callbacks, we would create a retain cycle once CoreFoundation + retains our `Reachability` class. This fixes the crashes and his how + CoreFoundation expects the API to be used, but doesn't play nicely with + Swift/ARC. This cycle would only be broken after manually calling + `stopNotifier()` — `deinit` would never be called. + + #### ReachabilityWeakifier + + By providing both retain/release callbacks and wrapping `Reachability` in + a weak wrapper, we: + - interact correctly with CoreFoundation, thereby avoiding a crash. + See "Memory Management Programming Guide for Core Foundation". + - don't alter the public API of `Reachability.swift` in any way + - still allow for automatic stopping of the notifier on `deinit`. + */ +private class ReachabilityWeakifier { + weak var reachability: Reachability? + init(reachability: Reachability) { + self.reachability = reachability + } +} diff --git a/ios/Classes/event/AudioItemEventProducer.swift b/ios/Classes/event/AudioItemEventProducer.swift new file mode 100644 index 0000000..ed5e5c7 --- /dev/null +++ b/ios/Classes/event/AudioItemEventProducer.swift @@ -0,0 +1,108 @@ +// +// AudioItemEventProducer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 13/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +// MARK: - AudioItem+KVO + +extension AudioItem { + //swiftlint:disable variable_name + /// The list of properties that is observed through KVO. + fileprivate static var ap_KVOProperties: [String] { + return ["artist", "title", "album", "trackCount", "trackNumber", "artwork"] + } +} + +// MARK: - PlayerEventProducer + +/// An `AudioItemEventProducer` generates event when a property of an `AudioItem` has changed. +class AudioItemEventProducer: NSObject, EventProducer { + /// An `AudioItemEvent` gets generated by `AudioItemEventProducer` when a property of `AudioItem` changes. + /// + /// - updatedArtist: `artist` was updated. + /// - updatedTitle: `title` was updated. + /// - updatedAlbum: `album` was updated. + /// - updatedTrackCount: `trackCount` was updated. + /// - updatedTrackNumber: `trackNumber` was updated. + /// - updatedArtwork: `artwork` was updated. + enum AudioItemEvent: String, Event { + case updatedArtist = "artist" + case updatedTitle = "title" + case updatedAlbum = "album" + case updatedTrackCount = "trackCount" + case updatedTrackNumber = "trackNumber" + case updatedArtwork = "artwork" + } + + /// The player to produce events with. + /// + /// Note that setting it has the same result as calling `stopProducingEvents`. + var item: AudioItem? { + willSet { + stopProducingEvents() + } + } + + /// The listener that will be alerted a new event occured. + weak var eventListener: EventListener? + + /// A boolean value indicating whether we're currently listening to events on the player. + private var listening = false + + /// Stops producing events on deinitialization. + deinit { + stopProducingEvents() + } + + /// Starts listening to the player events. + func startProducingEvents() { + guard let item = item, !listening else { + return + } + + //Observing AudioItem's property + for keyPath in AudioItem.ap_KVOProperties { + item.addObserver(self, forKeyPath: keyPath, options: .new, context: nil) + } + + listening = true + } + + /// Stops listening to the player events. + func stopProducingEvents() { + guard let item = item, listening else { + return + } + + //Unobserving AudioItem's property + for keyPath in AudioItem.ap_KVOProperties { + item.removeObserver(self, forKeyPath: keyPath) + } + + listening = false + } + + /// This message is sent to the receiver when the value at the specified key path relative to the given object has + /// changed. The receiver must be registered as an observer for the specified `keyPath` and `object`. + /// + /// - Parameters: + /// - keyPath: The key path, relative to `object`, to the value that has changed. + /// - object: The source object of the key path `keyPath`. + /// - change: A dictionary that describes the changes that have been made to the value of the property at the key + /// path `keyPath` relative to `object`. Entries are described in Change Dictionary Keys. + /// - context: The value that was provided when the receiver was registered to receive key-value observation + /// notifications. + override func observeValue(forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer?) { + if let event = keyPath.flatMap({ AudioItemEvent(rawValue: $0) }) { + eventListener?.onEvent(event, generetedBy: self) + } + } +} diff --git a/ios/Classes/event/EventProducer.swift b/ios/Classes/event/EventProducer.swift new file mode 100644 index 0000000..7b671a2 --- /dev/null +++ b/ios/Classes/event/EventProducer.swift @@ -0,0 +1,34 @@ +// +// EventProducer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 08/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +/// An `Event` represents an event that can occur. +protocol Event {} + +/// An `EventListener` listens to events generated by a `PlayerEventProducer`. +protocol EventListener: AnyObject { + /// Called when an event occurs. + /// + /// - Parameters: + /// - event: The event that occured. + /// - eventProducer: The producer at the root of the event. + func onEvent(_ event: Event, generetedBy eventProducer: EventProducer) +} + +/// An `EventProducer` serves the purpose of producing events over time. +protocol EventProducer: AnyObject { + /// The listener that will be alerted a new event occured. + var eventListener: EventListener? { get set } + + /// Tells the producer to start producing events. + func startProducingEvents() + + /// Tells the producer to stop producing events. + func stopProducingEvents() +} diff --git a/ios/Classes/event/NetworkEventProducer.swift b/ios/Classes/event/NetworkEventProducer.swift new file mode 100644 index 0000000..1d3eb8f --- /dev/null +++ b/ios/Classes/event/NetworkEventProducer.swift @@ -0,0 +1,118 @@ +// +// NetworkEventProducer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 08/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +private extension Selector { + /// The selector to call when reachability status changes. + static let reachabilityStatusChanged = + #selector(NetworkEventProducer.reachabilityStatusChanged(note:)) +} + +/// A `NetworkEventProducer` generates `NetworkEvent`s when there is changes on the network. +class NetworkEventProducer: NSObject, EventProducer { + /// A `NetworkEvent` is an event a network monitor. + /// + /// - networkChanged: The network changed. + /// - connectionRetrieved: The connection is now up. + /// - connectionLost: The connection has been lost. + enum NetworkEvent: Event { + case networkChanged + case connectionRetrieved + case connectionLost + } + + /// The reachability to work with. + let reachability: Reachability + + /// The date at which connection was lost. + private(set) var connectionLossDate: NSDate? + + /// The listener that will be alerted a new event occured. + weak var eventListener: EventListener? + + /// A boolean value indicating whether we're currently listening to events on the player. + private var listening = false + + /// The last status received. + private var lastConnection: Reachability.Connection? + + /// Initializes a `NetworkEventProducer` with a reachability. + /// + /// - Parameter reachability: The reachability to work with. + init(reachability: Reachability) { + self.reachability = reachability + + lastConnection = self.reachability.connection + if lastConnection == Reachability.Connection.unavailable { + connectionLossDate = NSDate() + } + } + + /// Stops producing events on deinitialization. + deinit { + stopProducingEvents() + } + + /// Starts listening to the player events. + func startProducingEvents() { + guard !listening else { + return + } + + // Saving current status + lastConnection = reachability.connection + + // Starting to listen to events + NotificationCenter.default.addObserver( + self, + selector: .reachabilityStatusChanged, + name: Notification.Name.reachabilityChanged, + object: reachability) + + do { + try reachability.startNotifier() + //Saving that we're currently listening + listening = true + } catch {} + } + + /// Stops listening to the player events. + func stopProducingEvents() { + guard listening else { + return + } + + // Stops listening to events + NotificationCenter.default.removeObserver( + self, name: Notification.Name.reachabilityChanged, object: reachability) + reachability.stopNotifier() + + // Saving that we're not listening anymore + listening = false + } + + /// The method that will be called when Reachability generates an event. + /// + /// - Parameter note: The notification information. + @objc fileprivate func reachabilityStatusChanged(note: NSNotification) { + let newConnection = reachability.connection + if newConnection != lastConnection { + if newConnection == Reachability.Connection.unavailable { + connectionLossDate = NSDate() + eventListener?.onEvent(NetworkEvent.connectionLost, generetedBy: self) + } else if lastConnection == Reachability.Connection.unavailable { + eventListener?.onEvent(NetworkEvent.connectionRetrieved, generetedBy: self) + connectionLossDate = nil + } else { + eventListener?.onEvent(NetworkEvent.networkChanged, generetedBy: self) + } + lastConnection = newConnection + } + } +} diff --git a/ios/Classes/event/PlayerEventProducer.swift b/ios/Classes/event/PlayerEventProducer.swift new file mode 100644 index 0000000..e447bb0 --- /dev/null +++ b/ios/Classes/event/PlayerEventProducer.swift @@ -0,0 +1,310 @@ +// +// PlayerEventProducer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 08/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import AVFoundation + +// MARK: - Selector+PlayerEventProducer + +private extension Selector { + #if os(iOS) || os(tvOS) + /// The selector to call when the audio session is interrupted. + static let audioSessionInterrupted = + #selector(PlayerEventProducer.audioSessionGotInterrupted(note:)) + + /// The selector to call when the audio session route changes. + static let audioRouteChanged = #selector(PlayerEventProducer.audioSessionRouteChanged(note:)) + + /// The selector to call when the audio session get messed up. + static let audioSessionMessedUp = #selector(PlayerEventProducer.audioSessionMessedUp(note:)) + + #endif + + /// The selector to call when an audio item ends playing. + static let itemDidEnd = #selector(PlayerEventProducer.playerItemDidEnd(note:)) +} + + +/// Custom errors which may be passed with PlayerEvent.endedPlaying event +enum EndedError: Error { + case ItemEndedEarly +} + +// MARK: - PlayerEventProducer + +/// A `PlayerEventProducer` listens to notifications and observes events generated by an AVPlayer. +class PlayerEventProducer: NSObject, EventProducer { + + //swiftlint:disable variable_name + /// The list of properties that is observed through KVO. + static var ap_KVOProperties: [String] { + return [ + "currentItem.playbackBufferEmpty", + "currentItem.playbackLikelyToKeepUp", + "currentItem.duration", + "currentItem.status", + "currentItem.loadedTimeRanges", + "currentItem.timedMetadata"] + } + + /// A `PlayerEvent` is an event a player generates over time. + /// + /// - startedBuffering: The player started buffering the audio file. + /// - readyToPlay: The player is ready to play. It buffered enough data. + /// - loadedMoreRange: The player loaded more range of time. + /// - loadedMetadata: The player loaded metadata. + /// - loadedDuration: The player has found audio item duration. + /// - progressed: The player progressed in its playing. + /// - endedPlaying: The player ended playing the current item because it went through the + /// file or because of an error. + /// - interruptionBegan: The player got interrupted (phone call, Siri, ...). + /// - interruptionEnded: The interruption ended. + /// - routeChanged: The player's route changed. + /// - sessionMessedUp: The audio session is messed up. + enum PlayerEvent: Event { + case startedBuffering + case readyToPlay + case playbackLikelyToKeepUp + case loadedMoreRange(CMTime, CMTime) + case loadedMetadata([AVMetadataItem]) + case loadedDuration(CMTime) + case progressed(CMTime) + case endedPlaying(Error?) + case interruptionBegan + case interruptionEnded(shouldResume: Bool) + case routeChanged(deviceDisconnected: Bool) + case sessionMessedUp + } + + /// The player to produce events with. + /// + /// Note that setting it has the same result as calling `stopProducingEvents`. + var player: AVPlayer? { + willSet { + stopProducingEvents() + } + } + + /// The listener that will be alerted a new event occured. + weak var eventListener: EventListener? + + /// The time observer for the player. + private var timeObserver: Any? + + /// A boolean value indicating whether we're currently listening to events on the player. + private var listening = false + + /// Stops producing events on deinitialization. + deinit { + stopProducingEvents() + } + + /// Starts listening to the player events. + func startProducingEvents() { + guard let player = player, !listening else { + return + } + + //Observing notifications sent through `NSNotificationCenter` + let center = NotificationCenter.default + #if os(iOS) || os(tvOS) + center.addObserver(self, + selector: .audioSessionInterrupted, + name: AVAudioSession.interruptionNotification, + object: nil) + center.addObserver( + self, + selector: .audioRouteChanged, + name: AVAudioSession.routeChangeNotification, + object: nil) + center.addObserver( + self, + selector: .audioSessionMessedUp, + name: AVAudioSession.mediaServicesWereLostNotification, + object: nil) + center.addObserver( + self, + selector: .audioSessionMessedUp, + name: AVAudioSession.mediaServicesWereResetNotification, + object: nil) + #endif + center.addObserver(self, selector: .itemDidEnd, name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem) + + //Observing AVPlayer's property + for keyPath in PlayerEventProducer.ap_KVOProperties { + player.addObserver(self, forKeyPath: keyPath, options: .new, context: nil) + } + + //Observing timing event + timeObserver = player.addPeriodicTimeObserver(forInterval: CMTimeMake(value: 1, timescale: 2), queue: .main) { [weak self] time in + guard let self = self else { + return + } + self.eventListener?.onEvent(PlayerEvent.progressed(time), generetedBy: self) + } + + listening = true + } + + /// Stops listening to the player events. + func stopProducingEvents() { + guard let player = player, listening else { + return + } + + //Unobserving notifications sent through `NSNotificationCenter` + let center = NotificationCenter.default + #if os(iOS) || os(tvOS) + center.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil) + center.removeObserver(self, name: AVAudioSession.routeChangeNotification, object: nil) + center.removeObserver(self, name: AVAudioSession.mediaServicesWereLostNotification, object: nil) + center.removeObserver(self, name: AVAudioSession.mediaServicesWereResetNotification, object: nil) + #endif + center.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: player.currentItem) + + //Unobserving AVPlayer's property + for keyPath in PlayerEventProducer.ap_KVOProperties { + player.removeObserver(self, forKeyPath: keyPath) + } + + //Unobserving timing event + if let timeObserver = timeObserver { + player.removeTimeObserver(timeObserver) + } + timeObserver = nil + + listening = false + } + + /// This message is sent to the receiver when the value at the specified key path relative to the given object has + /// changed. The receiver must be registered as an observer for the specified `keyPath` and `object`. + /// + /// - Parameters: + /// - keyPath: The key path, relative to `object`, to the value that has changed. + /// - object: The source object of the key path `keyPath`. + /// - change: A dictionary that describes the changes that have been made to the value of the property at the key + /// path `keyPath` relative to `object`. Entries are described in Change Dictionary Keys. + /// - context: The value that was provided when the receiver was registered to receive key-value observation + /// notifications. + override func observeValue(forKeyPath keyPath: String?, + of object: Any?, + change: [NSKeyValueChangeKey: Any]?, + context: UnsafeMutableRawPointer?) { + if let keyPath = keyPath, let p = object as? AVPlayer, let currentItem = p.currentItem { + switch keyPath { + case "currentItem.duration": + let duration = currentItem.duration + eventListener?.onEvent(PlayerEvent.loadedDuration(duration), generetedBy: self) + + let metadata = currentItem.asset.commonMetadata + eventListener?.onEvent(PlayerEvent.loadedMetadata(metadata), generetedBy: self) + + case "currentItem.playbackBufferEmpty" where currentItem.isPlaybackBufferEmpty: + eventListener?.onEvent(PlayerEvent.startedBuffering, generetedBy: self) + + case "currentItem.playbackLikelyToKeepUp" where currentItem.isPlaybackLikelyToKeepUp: + eventListener?.onEvent(PlayerEvent.playbackLikelyToKeepUp, generetedBy: self) + + case "currentItem.status" where currentItem.status == .failed: + eventListener?.onEvent( + PlayerEvent.endedPlaying(currentItem.error), generetedBy: self) + + case "currentItem.status" where currentItem.status == .readyToPlay: + eventListener?.onEvent(PlayerEvent.readyToPlay, generetedBy: self) + + case "currentItem.loadedTimeRanges": + if let range = currentItem.loadedTimeRanges.last?.timeRangeValue { + eventListener?.onEvent( + PlayerEvent.loadedMoreRange(range.start, range.end), generetedBy: self) + } + + case "currentItem.timedMetadata": + if let metadata = currentItem.timedMetadata { + eventListener?.onEvent(PlayerEvent.loadedMetadata(metadata), generetedBy: self) + } + + default: + break + } + } + } + + #if os(iOS) || os(tvOS) + /// Audio session got interrupted by the system (call, Siri, ...). If interruption begins, we should ensure the + /// audio pauses and if it ends, we should restart playing if state was `.playing` before. + /// + /// - Parameter note: The notification information. + @objc fileprivate func audioSessionGotInterrupted(note: NSNotification) { + guard let userInfo = note.userInfo, + let type = (userInfo[AVAudioSessionInterruptionTypeKey] as? UInt) + .map(AVAudioSession.InterruptionType.init) else { + return + } + switch type { + case .began: + eventListener?.onEvent(PlayerEvent.interruptionBegan, generetedBy: self) + case .ended: + if let options = (userInfo[AVAudioSessionInterruptionOptionKey] as? UInt) + .map(AVAudioSession.InterruptionOptions.init) { + eventListener?.onEvent( + PlayerEvent.interruptionEnded(shouldResume: options.contains(.shouldResume)), + generetedBy: self + ) + } + default: + break + } + } + + /// Audio session route changed (ex: earbuds plugged in/out). This can change the player state, so we just adapt it. + /// + /// - Parameter note: The notification information. + @objc fileprivate func audioSessionRouteChanged(note: NSNotification) { + let reason = note.userInfo + .flatMap({ $0[AVAudioSessionRouteChangeReasonKey] as? UInt }) + .map(AVAudioSession.RouteChangeReason.init) as? AVAudioSession.RouteChangeReason ?? .unknown + let deviceDisconnected = reason == .oldDeviceUnavailable + eventListener?.onEvent(PlayerEvent.routeChanged(deviceDisconnected: deviceDisconnected), generetedBy: self) + } + + /// Audio session got messed up (media services lost or reset). We gotta reactive the audio session and reset + /// player. + /// + /// - Parameter note: The notification information. + @objc fileprivate func audioSessionMessedUp(note: NSNotification) { + eventListener?.onEvent(PlayerEvent.sessionMessedUp, generetedBy: self) + } + #endif + + /// Playing item did end. We can play next or stop the player if queue is empty. + /// + /// - Parameter note: The notification information. + @objc fileprivate func playerItemDidEnd(note: NSNotification) { + if let _ = player?.currentItem { + if currentItemEndedBeforeDuration() { + // AVPlayer sent playerItemDidEnd, but duration and currentTime has a diff larger than 1 second + // This could happen when internet connection is lost during playback + eventListener?.onEvent(PlayerEvent.endedPlaying(EndedError.ItemEndedEarly), generetedBy: self) + } else { + // succesfully played to end of item + eventListener?.onEvent(PlayerEvent.endedPlaying(nil), generetedBy: self) + } + } + + } + + fileprivate func currentItemEndedBeforeDuration() -> Bool { + guard let currentItem = player?.currentItem, + currentItem.duration.seconds.isNormal else { + return false + } + let timeDiff = currentItem.duration.seconds - currentItem.currentTime().seconds + return timeDiff > PlayerEventProducer.AcceptableItemEndedDifference + } + + private static var AcceptableItemEndedDifference: Double = 5.0 +} diff --git a/ios/Classes/event/QualityAdjustmentEventProducer.swift b/ios/Classes/event/QualityAdjustmentEventProducer.swift new file mode 100644 index 0000000..1bb1c49 --- /dev/null +++ b/ios/Classes/event/QualityAdjustmentEventProducer.swift @@ -0,0 +1,153 @@ +// +// QualityAdjustmentEventProducer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 11/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +private extension Selector { + /// The selector to call when the timer ticks. + static let timerTicked = #selector(QualityAdjustmentEventProducer.timerTicked(_:)) +} + +/// A `QualityAdjustmentEventProducer` generates `QualityAdjustmentEvent`s when there should be a change of quality +/// based on some information about interruptions. +class QualityAdjustmentEventProducer: NSObject, EventProducer { + /// `QualityAdjustmentEvent` is a list of event that can be generated by `QualityAdjustmentEventProducer`. + /// + /// - goDown: The quality should go down if possible. + /// - goUp: The quality should go up if possible. + enum QualityAdjustmentEvent: Event { + case goDown + case goUp + } + + /// The timer used to adjust quality + private var timer: Timer? + + /// The listener that will be alerted a new event occured. + weak var eventListener: EventListener? + + /// A boolean value indicating whether we're currently producing events or not. + private var listening = false + + /// Interruption counter. It will be used to determine whether the quality should change. + var interruptionCount = 0 { + didSet { + checkInterruptionCount() + } + } + + /// Defines the delay within which the player wait for an interruption before upgrading the quality. Default value + /// is 10 minutes. + var adjustQualityTimeInternal = TimeInterval(10 * 60) { + didSet { + if let timer = timer, listening { + //We don't want to reset state in here because we want to keep the interruption + //count and we also have to change the timer fire date. + let delta = adjustQualityTimeInternal - oldValue + let newFireDate = timer.fireDate.addingTimeInterval(delta) + let timeInterval = newFireDate.timeIntervalSinceNow + + timer.invalidate() + + if timeInterval < 1 { + //In this case, the timer should have been fired based on the last + //fire date and the new `adjustQualityTimeInternal`. So we fire now. + timerTicked(timer) + } else { + //In this case, the timer fire date just needs to be adjusted. + self.timer = Timer.scheduledTimer( + timeInterval: timeInterval, + target: self, + selector: .timerTicked, + userInfo: nil, + repeats: false) + } + } + } + } + + /// Defines the maximum number of interruption to have within the `adjustQualityTimeInterval` delay before + /// downgrading the quality. Default value is 5. + var adjustQualityAfterInterruptionCount = 5 { + didSet { + checkInterruptionCount() + } + } + + + /// Stops producing events on deinitialization. + deinit { + stopProducingEvents() + } + + /// Starts listening to the player events. + func startProducingEvents() { + guard !listening else { + return + } + + //Reset state + resetState() + + //Saving that we're currently listening + listening = true + } + + /// Stops listening to the player events. + func stopProducingEvents() { + guard listening else { + return + } + + timer?.invalidate() + timer = nil + + //Saving that we're not listening anymore + listening = false + } + + /// Resets the state. + private func resetState() { + interruptionCount = 0 + + timer?.invalidate() + timer = Timer.scheduledTimer( + timeInterval: adjustQualityTimeInternal, + target: self, + selector: .timerTicked, + userInfo: nil, + repeats: false) + } + + /// Checks that the interruption count is lower than `adjustQualityAfterInterruptionCount`. If it isn't, the + /// function generates an event and reset its state. + private func checkInterruptionCount() { + if interruptionCount >= adjustQualityAfterInterruptionCount && listening { + //Now we need to stop the timer + timer?.invalidate() + + //Calls the listener + eventListener?.onEvent(QualityAdjustmentEvent.goDown, generetedBy: self) + + //Reset state + resetState() + } + } + + /// The quality adjuster ticked. + /// + /// - Parameter _: The timer. + @objc fileprivate func timerTicked(_: AnyObject) { + if interruptionCount == 0 { + eventListener?.onEvent(QualityAdjustmentEvent.goUp, generetedBy: self) + } + + //Reset state + resetState() + } +} diff --git a/ios/Classes/event/RetryEventProducer.swift b/ios/Classes/event/RetryEventProducer.swift new file mode 100644 index 0000000..58a204c --- /dev/null +++ b/ios/Classes/event/RetryEventProducer.swift @@ -0,0 +1,106 @@ +// +// RetryEventProducer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 10/04/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +private extension Selector { + /// The selector to call when the timer ticks. + static let timerTicked = #selector(RetryEventProducer.timerTicked(_:)) +} + +/// A `RetryEventProducer` generates `RetryEvent`s when there should be a retry based on some information about +/// interruptions. +class RetryEventProducer: NSObject, EventProducer { + /// `RetryEvent` is a list of event that can be generated by `RetryEventProducer`. + /// + /// - retryAvailable: A retry is available. + /// - retryFailed: Retrying is no longer an option. + enum RetryEvent: Event { + case retryAvailable + case retryFailed + } + + /// The timer used to adjust quality + private var timer: Timer? + + /// The listener that will be alerted a new event occured. + weak var eventListener: EventListener? + + /// A boolean value indicating whether we're currently producing events or not. + private var listening = false + + /// Interruption counter. It will be used to determine whether the quality should change. + private var retryCount = 0 + + /// The maximum number of interruption before generating an event. Default value is 10. + var maximumRetryCount = 10 + + /// The delay to wait before cancelling last retry and retrying. Default value is 10 seconds. + var retryTimeout = TimeInterval(10) + + + /// Stops producing events on deinitialization. + deinit { + stopProducingEvents() + } + + /// Starts listening to the player events. + func startProducingEvents() { + guard !listening else { + return + } + + //Reset state + retryCount = 0 + + //Creates a new timer for next retry + restartTimer() + + //Saving that we're currently listening + listening = true + } + + /// Stops listening to the player events. + func stopProducingEvents() { + guard listening else { + return + } + + timer?.invalidate() + timer = nil + + //Saving that we're not listening anymore + listening = false + } + + /// Stops the current timer if any and restart a new one. + private func restartTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer( + timeInterval: retryTimeout, + target: self, + selector: .timerTicked, + userInfo: nil, + repeats: false) + } + + /// The retry timer ticked. + /// + /// - Parameter _: The timer. + @objc fileprivate func timerTicked(_: AnyObject) { + retryCount += 1 + + if retryCount < maximumRetryCount { + eventListener?.onEvent(RetryEvent.retryAvailable, generetedBy: self) + + restartTimer() + } else { + eventListener?.onEvent(RetryEvent.retryFailed, generetedBy: self) + } + } +} diff --git a/ios/Classes/event/SeekEventProducer.swift b/ios/Classes/event/SeekEventProducer.swift new file mode 100644 index 0000000..5f614b1 --- /dev/null +++ b/ios/Classes/event/SeekEventProducer.swift @@ -0,0 +1,92 @@ +// +// SeekEventProducer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 2016-10-27. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +private extension Selector { + /// The selector to call when the timer ticks. + static let timerTicked = #selector(SeekEventProducer.timerTicked(_:)) +} + +/// A `SeekEventProducer` generates `SeekEvent`s when it's time to seek on the stream. +@objcMembers class SeekEventProducer: NSObject, EventProducer { + /// `SeekEvent` is an event generated by `SeekEventProducer`. + /// + /// - seekBackward: The event describes a seek backward in time. + /// - seekForward: The event describes a seek forward in time. + enum SeekEvent: Event { + case seekBackward + case seekForward + } + + /// The timer used to generate events. + private var timer: Timer? + + /// The listener that will be alerted a new event occured. + weak var eventListener: EventListener? + + /// A boolean value indicating whether we're currently producing events or not. + private var listening = false + + /// The delay to wait before cancelling last retry and retrying. Default value is 10 seconds. + var intervalBetweenEvents = TimeInterval(10) + + /// A boolean value indicating whether the producer should generate backward or forward events. + var isBackward = false + + + /// Stops producing events on deinitialization. + deinit { + stopProducingEvents() + } + + /// Starts listening to the player events. + func startProducingEvents() { + guard !listening else { + return + } + + //Creates a new timer for next retry + restartTimer() + + //Saving that we're currently listening + listening = true + } + + /// Stops listening to the player events. + func stopProducingEvents() { + guard listening else { + return + } + + timer?.invalidate() + timer = nil + + //Saving that we're not listening anymore + listening = false + } + + /// Stops the current timer if any and restart a new one. + private func restartTimer() { + timer?.invalidate() + timer = Timer.scheduledTimer( + timeInterval: intervalBetweenEvents, + target: self, + selector: .timerTicked, + userInfo: nil, + repeats: false) + } + + /// The retry timer ticked. + /// + /// - Parameter _: The timer. + @objc fileprivate func timerTicked(_: AnyObject) { + eventListener?.onEvent(isBackward ? SeekEvent.seekBackward : .seekForward, generetedBy: self) + restartTimer() + } +} diff --git a/ios/Classes/item/AudioItem.swift b/ios/Classes/item/AudioItem.swift new file mode 100644 index 0000000..9b7e716 --- /dev/null +++ b/ios/Classes/item/AudioItem.swift @@ -0,0 +1,229 @@ +// +// AudioItem.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 12/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import AVFoundation +#if os(iOS) || os(tvOS) + import UIKit + import MediaPlayer + + public typealias Image = UIImage +#else + import Cocoa + + public typealias Image = NSImage +#endif + +// MARK: - AudioQuality + +/// `AudioQuality` differentiates qualities for audio. +/// +/// - low: The lowest quality. +/// - medium: The quality between highest and lowest. +/// - high: The highest quality. +public enum AudioQuality: Int { + case low = 0 + case medium = 1 + case high = 2 +} + +// MARK: - AudioItemURL + +/// `AudioItemURL` contains information about an Item URL such as its quality. +@objcMembers public class AudioItemURL: NSObject { + /// The quality of the stream. + public let quality: AudioQuality + + /// The url of the stream. + public let url: URL + + /// Initializes an AudioItemURL. + /// + /// - Parameters: + /// - quality: The quality of the stream. + /// - url: The url of the stream. + public init?(quality: AudioQuality, url: URL?) { + guard let url = url else { return nil } + + self.quality = quality + self.url = url + } +} + +// MARK: - AudioItem + +/// An `AudioItem` instance contains every piece of information needed for an `AudioPlayer` to play. +/// +/// URLs can be remote or local. +@objcMembers open class AudioItem: NSObject { + /// Returns the available qualities. + public let soundURLs: [AudioQuality: URL] + + // MARK: Initialization + + /// Initializes an AudioItem. Fails if every urls are nil. + /// + /// - Parameters: + /// - highQualitySoundURL: The URL for the high quality sound. + /// - mediumQualitySoundURL: The URL for the medium quality sound. + /// - lowQualitySoundURL: The URL for the low quality sound. + public convenience init?(highQualitySoundURL: URL? = nil, + mediumQualitySoundURL: URL? = nil, + lowQualitySoundURL: URL? = nil) { + var URLs = [AudioQuality: URL]() + if let highURL = highQualitySoundURL { + URLs[.high] = highURL + } + if let mediumURL = mediumQualitySoundURL { + URLs[.medium] = mediumURL + } + if let lowURL = lowQualitySoundURL { + URLs[.low] = lowURL + } + self.init(soundURLs: URLs) + } + + /// Initializes an `AudioItem`. + /// + /// - Parameter soundURLs: The URLs of the sound associated with its quality wrapped in a `Dictionary`. + public init?(soundURLs: [AudioQuality: URL]) { + self.soundURLs = soundURLs + super.init() + + if soundURLs.isEmpty { + return nil + } + } + + // MARK: Quality selection + + /// Returns the highest quality URL found or nil if no URLs are available + open var highestQualityURL: AudioItemURL { + //swiftlint:disable force_unwrapping + return (AudioItemURL(quality: .high, url: soundURLs[.high]) ?? + AudioItemURL(quality: .medium, url: soundURLs[.medium]) ?? + AudioItemURL(quality: .low, url: soundURLs[.low]))! + } + + /// Returns the medium quality URL found or nil if no URLs are available + open var mediumQualityURL: AudioItemURL { + //swiftlint:disable force_unwrapping + return (AudioItemURL(quality: .medium, url: soundURLs[.medium]) ?? + AudioItemURL(quality: .low, url: soundURLs[.low]) ?? + AudioItemURL(quality: .high, url: soundURLs[.high]))! + } + + /// Returns the lowest quality URL found or nil if no URLs are available + open var lowestQualityURL: AudioItemURL { + //swiftlint:disable force_unwrapping + return (AudioItemURL(quality: .low, url: soundURLs[.low]) ?? + AudioItemURL(quality: .medium, url: soundURLs[.medium]) ?? + AudioItemURL(quality: .high, url: soundURLs[.high]))! + } + + /// Returns an URL that best fits a given quality. + /// + /// - Parameter quality: The quality for the requested URL. + /// - Returns: The URL that best fits the given quality. + func url(for quality: AudioQuality) -> AudioItemURL { + switch quality { + case .high: + return highestQualityURL + case .medium: + return mediumQualityURL + default: + return lowestQualityURL + } + } + + // MARK: Additional properties + + /// The artist of the item. + /// + /// This can change over time which is why the property is dynamic. It enables KVO on the property. + @objc open dynamic var artist: String? + + /// The title of the item. + /// + /// This can change over time which is why the property is dynamic. It enables KVO on the property. + @objc open dynamic var title: String? + + /// The album of the item. + /// + /// This can change over time which is why the property is dynamic. It enables KVO on the property. + @objc open dynamic var album: String? + + ///The track count of the item's album. + /// + /// This can change over time which is why the property is dynamic. It enables KVO on the property. + @objc open dynamic var trackCount: NSNumber? + + /// The track number of the item in its album. + /// + /// This can change over time which is why the property is dynamic. It enables KVO on the property. + @objc open dynamic var trackNumber: NSNumber? + + /// The artwork image of the item. + open var artworkImage: Image? { + get { + #if os(OSX) + return artwork + #else + return artwork?.image(at: imageSize ?? CGSize(width: 512, height: 512)) + #endif + } + set { + #if os(OSX) + artwork = newValue + #else + imageSize = newValue?.size + artwork = newValue.map { image in + return MPMediaItemArtwork(boundsSize: image.size) { _ in image } + } + #endif + } + } + + /// The artwork image of the item. + /// + /// This can change over time which is why the property is dynamic. It enables KVO on the property. + #if os(OSX) + @objc open dynamic var artwork: Image? + #else + @objc open dynamic var artwork: MPMediaItemArtwork? + + /// The image size. + private var imageSize: CGSize? + #endif + + // MARK: Metadata + + /// Parses the metadata coming from the stream/file specified in the URL's. The default behavior is to set values + /// for every property that is nil. Customization is available through subclassing. + /// + /// - Parameter items: The metadata items. + open func parseMetadata(_ items: [AVMetadataItem]) { + items.forEach { + if let commonKey = $0.commonKey { + switch commonKey { + case AVMetadataKey.commonKeyTitle where title == nil: + title = $0.value as? String + case AVMetadataKey.commonKeyArtist where artist == nil: + artist = $0.value as? String + case AVMetadataKey.commonKeyAlbumName where album == nil: + album = $0.value as? String + case AVMetadataKey.id3MetadataKeyTrackNumber where trackNumber == nil: + trackNumber = $0.value as? NSNumber + case AVMetadataKey.commonKeyArtwork where artwork == nil: + artworkImage = ($0.value as? Data).flatMap { Image(data: $0) } + default: + break + } + } + } + } +} diff --git a/ios/Classes/item/AudioItemQueue.swift b/ios/Classes/item/AudioItemQueue.swift new file mode 100644 index 0000000..e707d58 --- /dev/null +++ b/ios/Classes/item/AudioItemQueue.swift @@ -0,0 +1,232 @@ +// +// AudioItemQueue.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 11/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +// MARK: - Array+Shuffe + +private extension Array { + /// Shuffles the element in the array and returns the new array. + /// + /// - Returns: A shuffled array. + func ap_shuffled() -> [Element] { + return sorted { element1, element2 in + arc4random() % 2 == 0 + } + } +} + +// MARK: - AudioItemQueueDelegate + +/// `AudioItemQueueDelegate` defines the behavior of `AudioItem` in certain circumstances and is notified upon notable +/// events. +protocol AudioItemQueueDelegate: AnyObject { + /// Returns a boolean value indicating whether an item should be consider playable in the queue. + /// + /// - Parameters: + /// - queue: The queue. + /// - item: The item we ask the information for. + /// - Returns: A boolean value indicating whether an item should be consider playable in the queue. + func audioItemQueue(_ queue: AudioItemQueue, shouldConsiderItem item: AudioItem) -> Bool +} + +// MARK: - AudioItemQueue + +/// `AudioItemQueue` handles queueing items with a playing mode. +class AudioItemQueue { + /// The original items, keeping the same order. + private(set) var items: [AudioItem] + + /// The items stored in the way the mode requires. + private(set) var queue: [AudioItem] + + /// The historic of items played in the queue. + private(set) var historic: [AudioItem] + + /// The current position in the queue. + var nextPosition = 0 + + /// The player mode. It will affect the queue. + var mode: AudioPlayerMode { + didSet { + adaptQueue(oldMode: oldValue) + } + } + + /// The queue delegate. + weak var delegate: AudioItemQueueDelegate? + + /// Initializes a queue with a list of items and the mode. + /// + /// - Parameters: + /// - items: The list of items to play. + /// - mode: The mode to play items with. + init(items: [AudioItem], mode: AudioPlayerMode) { + self.items = items + self.mode = mode + queue = mode.contains(.shuffle) ? items.ap_shuffled() : items + historic = [] + } + + /// Adapts the queue to the new mode. + /// + /// Behaviour is: + /// - `oldMode` contains .Repeat, `mode` doesn't and last item played == nextItem, we increment position. + /// - `oldMode` contains .Shuffle, `mode` doesnt. We should set the queue to `items` and set current position to the + /// current item index in the new queue. + /// - `mode` contains .Shuffle, `oldMode` doesn't. We should shuffle the leftover items in queue. + /// + /// Also, the items already played should also be shuffled. Current implementation has a limitation which is that + /// the "already played items" will be shuffled at the begining of the queue while the leftovers will be shuffled at + /// the end of the array. + /// + /// - Parameter oldMode: The mode before it changed. + private func adaptQueue(oldMode: AudioPlayerMode) { + //Early exit if queue is empty + guard !queue.isEmpty else { + return + } + + if !oldMode.contains(.repeatAll) && mode.contains(.repeatAll) { + nextPosition = nextPosition % queue.count + } + + if oldMode.contains(.repeat) && !mode.contains(.repeat) && historic.last == queue[nextPosition] { + nextPosition += 1 + } else if !oldMode.contains(.repeat) && mode.contains(.repeat) && nextPosition == queue.count { + nextPosition -= 1 + } + + if oldMode.contains(.shuffle) && !mode.contains(.shuffle) { + queue = items + if let last = historic.last, let index = queue.firstIndex(of: last) { + nextPosition = index + 1 + } + } else if mode.contains(.shuffle) && !oldMode.contains(.shuffle) { + let alreadyPlayed = queue.prefix(upTo: nextPosition) + let leftovers = queue.suffix(from: nextPosition) + queue = Array(alreadyPlayed).ap_shuffled() + Array(leftovers).ap_shuffled() + } + } + + /// Returns the next item in the queue. + /// + /// - Returns: The next item in the queue. + func nextItem() -> AudioItem? { + //Early exit if queue is empty + guard !queue.isEmpty else { + return nil + } + + if mode.contains(.repeat) { + //No matter if we should still consider this item, the repeat mode will return the current item. + let item = queue[nextPosition] + historic.append(item) + return item + } + + if mode.contains(.repeatAll) && nextPosition >= queue.count { + nextPosition = 0 + } + + while nextPosition < queue.count { + let item = queue[nextPosition] + nextPosition += 1 + + if shouldConsiderItem(item: item) { + historic.append(item) + return item + } + } + + if mode.contains(.repeatAll) && nextPosition >= queue.count { + nextPosition = 0 + } + return nil + } + + /// A boolean value indicating whether the queue has a next item to play or not. + var hasNextItem: Bool { + if !queue.isEmpty && + (queue.count > nextPosition || mode.contains(.repeat) || mode.contains(.repeatAll)) { + return true + } + return false + } + + /// Returns the previous item in the queue. + /// + /// - Returns: The previous item in the queue. + func previousItem() -> AudioItem? { + //Early exit if queue is empty + guard !queue.isEmpty else { + return nil + } + + if mode.contains(.repeat) { + //No matter if we should still consider this item, the repeat mode will return the current item. + let item = queue[max(0, nextPosition - 1)] + historic.append(item) + return item + } + + if mode.contains(.repeatAll) && nextPosition <= 0 { + nextPosition = queue.count + } + + while nextPosition > 0 { + let previousPosition = nextPosition - 1 + nextPosition = previousPosition + let item = queue[previousPosition] + + if shouldConsiderItem(item: item) { + historic.append(item) + return item + } + } + + if mode.contains(.repeatAll) && nextPosition <= 0 { + nextPosition = queue.count + } + return nil + } + + /// A boolean value indicating whether the queue has a previous item to play or not. + var hasPreviousItem: Bool { + if !queue.isEmpty && + (nextPosition > 0 || mode.contains(.repeat) || mode.contains(.repeatAll)) { + return true + } + return false + } + + /// Adds a list of items to the queue. + /// + /// - Parameter items: The items to add to the queue. + func add(items: [AudioItem]) { + self.items.append(contentsOf: items) + self.queue.append(contentsOf: items) + } + + /// Removes an item from the queue. + /// + /// - Parameter index: The index of the item to remove. + func remove(at index: Int) { + let item = queue.remove(at: index) + if let index = items.firstIndex(of: item) { + items.remove(at: index) + } + } + + /// Returns a boolean value indicating whether an item should be consider playable in the queue. + /// + /// - Returns: A boolean value indicating whether an item should be consider playable in the queue. + private func shouldConsiderItem(item: AudioItem) -> Bool { + return delegate?.audioItemQueue(self, shouldConsiderItem: item) ?? true + } +} diff --git a/ios/Classes/item/SeekOperation.swift b/ios/Classes/item/SeekOperation.swift new file mode 100644 index 0000000..678b73f --- /dev/null +++ b/ios/Classes/item/SeekOperation.swift @@ -0,0 +1,30 @@ +// +// SeekOperation.swift +// AudioPlayer +// +// Created by Daniel Dam Freiling on 13/06/2017. +// Copyright © 2017 Kevin Delannoy. All rights reserved. +// + +import CoreMedia + +@objcMembers public class SeekOperation: NSObject { + + init(time: TimeInterval, + adaptToFitSeekableRanges: Bool = false, + toleranceBefore: CMTime = CMTime.positiveInfinity, + toleranceAfter: CMTime = CMTime.positiveInfinity, + completionHandler: ((Bool) -> Void)? = nil) { + self.time = time + self.adaptToFitSeekableRanges = adaptToFitSeekableRanges + self.toleranceBefore = toleranceBefore + self.toleranceAfter = toleranceAfter + self.completionHandler = completionHandler + } + + public var time: TimeInterval + public var adaptToFitSeekableRanges: Bool + public var toleranceBefore: CMTime + public var toleranceAfter: CMTime + public var completionHandler: ((Bool) -> Void)? +} diff --git a/ios/Classes/player/AudioPlayer.swift b/ios/Classes/player/AudioPlayer.swift new file mode 100644 index 0000000..704f285 --- /dev/null +++ b/ios/Classes/player/AudioPlayer.swift @@ -0,0 +1,525 @@ +// +// AudioPlayer.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 26/04/15. +// Copyright (c) 2015 Kevin Delannoy. All rights reserved. +// + +import AVFoundation +#if os(iOS) || os(tvOS) + import MediaPlayer +#endif + +/// An `AudioPlayer` instance is used to play `AudioPlayerItem`. It's an easy to use AVPlayer with simple methods to +/// handle the whole playing audio process. +/// +/// You can get events (such as state change or time observation) by registering a delegate. +@objcMembers public class AudioPlayer: NSObject { + // MARK: Handlers + + /// The background handler. + let backgroundHandler = BackgroundHandler() + + /// Reachability for network connection. + let reachability = try! Reachability() + + // MARK: Event producers + + /// The network event producer. + lazy var networkEventProducer: NetworkEventProducer = { + NetworkEventProducer(reachability: self.reachability) + }() + + /// The player event producer. + let playerEventProducer = PlayerEventProducer() + + /// The seek event producer. + let seekEventProducer = SeekEventProducer() + + /// The quality adjustment event producer. + var qualityAdjustmentEventProducer = QualityAdjustmentEventProducer() + + /// The audio item event producer. + var audioItemEventProducer = AudioItemEventProducer() + + /// The retry event producer. + var retryEventProducer = RetryEventProducer() + + // MARK: Player + + /// The queue containing items to play. + var queue: AudioItemQueue? + + /// Cached AVAssets, mainly used for preloading next item. + var cachedAssets: [URL: AVURLAsset] = [:] + + /// The audio player. + var player: AVPlayer? { + didSet { + player?.allowsExternalPlayback = allowExternalPlayback + player?.volume = volume + player?.rate = rate + updatePlayerForBufferingStrategy() + + if let player = player { + playerEventProducer.player = player + audioItemEventProducer.item = currentItem + playerEventProducer.startProducingEvents() + audioItemEventProducer.startProducingEvents() + qualityAdjustmentEventProducer.startProducingEvents() + + // Start producing network events, if not already doing so + networkEventProducer.startProducingEvents() + if #available(OSX 10.12.2, *) { + registerRemoteControlCommands() + } + } else { + playerEventProducer.player = nil + audioItemEventProducer.item = nil + playerEventProducer.stopProducingEvents() + audioItemEventProducer.stopProducingEvents() + qualityAdjustmentEventProducer.stopProducingEvents() + } + } + } + + /// The current item being played. + public internal(set) var currentItem: AudioItem? { + didSet { + if let currentItem = currentItem { + //Stops the current player + player?.rate = 0 + player = nil + + //Ensures the audio session is active + setAudioSession(active: true) + + //Reset special state flags + pausedForInterruption = false + stateBeforeBuffering = nil + stateWhenConnectionLost = nil + queuedSeek = nil + + //Sets new state + let info = currentItem.url(for: currentQuality) + if isOnline || info.url.ap_isOfflineURL { + state = .buffering + } else { + stateWhenConnectionLost = .buffering + state = .waitingForConnection + return + } + + //Reset special state flags + pausedForInterruption = false + + //Create new AVPlayerItem + let playerItem = getAVPlayerItem(forUrl: info.url) + + //Creates new player + player = AVPlayer(playerItem: playerItem) + + currentQuality = info.quality + + //Updates information on the lock screen + updateNowPlayingInfoCenter() + + //Calls delegate + if oldValue != currentItem { + delegate?.audioPlayer?(self, willStartPlaying: currentItem) + } + player?.rate = rate + } else if (player != nil) { + stop() + } + } + } + + /// The latest error on failed state + public var failedError: Error? + + // MARK: Public properties + + /// The delegate that will be called upon events. + public weak var delegate: AudioPlayerDelegate? + + /// Defines the maximum to wait after a connection loss before putting the player to Stopped mode and cancelling + /// the resume. Default value is 60 seconds. + public var maximumConnectionLossTime = TimeInterval(60) + + /// Defines whether the player should automatically adjust sound quality based on the number of interruption before + /// a delay and the maximum number of interruption whithin this delay. Default value is `true`. + public var adjustQualityAutomatically = true + + /// Defines the default quality used to play. Default value is `.medium` + public var defaultQuality = AudioQuality.medium + + /// Defines the delay within which the player wait for an interruption before upgrading the quality. Default value + /// is 10 minutes. + public var adjustQualityTimeInternal: TimeInterval { + get { + return qualityAdjustmentEventProducer.adjustQualityTimeInternal + } + set { + qualityAdjustmentEventProducer.adjustQualityTimeInternal = newValue + } + } + + /// Defines the maximum number of interruption to have within the `adjustQualityTimeInterval` delay before + /// downgrading the quality. Default value is 5. + public var adjustQualityAfterInterruptionCount: Int { + get { + return qualityAdjustmentEventProducer.adjustQualityAfterInterruptionCount + } + set { + qualityAdjustmentEventProducer.adjustQualityAfterInterruptionCount = newValue + } + } + + /// The maximum number of interruption before putting the player to Stopped mode. Default value is 10. + public var maximumRetryCount: Int { + get { + return retryEventProducer.maximumRetryCount + } + set { + retryEventProducer.maximumRetryCount = newValue + } + } + + /// The delay to wait before cancelling last retry and retrying. Default value is 10 seconds. + public var retryTimeout: TimeInterval { + get { + return retryEventProducer.retryTimeout + } + set { + retryEventProducer.retryTimeout = newValue + } + } + + /// Defines whether external playback to AirPlay devices is enabled. Default value is `true` + public var allowExternalPlayback = true + + /// Defines which audio session category to set. Default value is `AVAudioSession.Category.playback`. + @objc(sessionCategory) + public var objc_sessionCategory = AVAudioSession.Category.playback.rawValue { + didSet { + self.sessionCategory = AVAudioSession.Category.init(rawValue: objc_sessionCategory) + } + } + /// Defines which audio session category to set. Default value is `AVAudioSession.Category.playback`. + @nonobjc + public var sessionCategory = AVAudioSession.Category.playback + + /// Defines which audio session mode to set. Default value is `AVAudioSession.Mode.default`. + @objc(sessionMode) + public var objc_sessionMode = AVAudioSession.Mode.default.rawValue { + didSet { + self.sessionMode = AVAudioSession.Mode.init(rawValue: objc_sessionMode) + } + } + /// Defines which audio session mode to set. Default value is `AVAudioSession.Mode.default`. + @nonobjc + public var sessionMode = AVAudioSession.Mode.default + + /// Defines which time pitch algorithm to use. Default value is `AVAudioTimePitchAlgorithm.lowQualityZeroLatency`. + @objc(timePitchAlgorithm) + public var objc_timePitchAlgorithm = AVAudioTimePitchAlgorithm.lowQualityZeroLatency.rawValue { + didSet { + self.timePitchAlgorithm = AVAudioTimePitchAlgorithm.init(rawValue: objc_timePitchAlgorithm) + } + } + /// Defines which time pitch algorithm to use. Default value is `AVAudioTimePitchAlgorithm.lowQualityZeroLatency`. + @nonobjc + public var timePitchAlgorithm = AVAudioTimePitchAlgorithm.lowQualityZeroLatency + + /// Defines whether the player should resume after a system interruption or not. Default value is `true`. + public var resumeAfterInterruption = true + + /// Defines whether the player should resume after a connection loss or not. Default value is `true`. + public var resumeAfterConnectionLoss = true + + /// Defines the mode of the player. Default is `.Normal`. + public var mode = AudioPlayerMode.normal { + didSet { + queue?.mode = mode + } + } + + /// Defines the volume of the player. `1.0` means 100% and `0.0` is 0%. + public var volume = Float(1) { + didSet { + player?.volume = volume + } + } + + /// Defines the rate of the player. Default value is 1. + public var rate = Float(1) { + didSet { + if case .playing = state { + player?.rate = rate + updateNowPlayingInfoCenter() + } + } + } + + /// Defines the buffering strategy used to determine how much to buffer before starting playback + public var bufferingStrategy: AudioPlayerBufferingStrategy = .defaultBuffering { + didSet { + updatePlayerForBufferingStrategy() + } + } + + /// Defines the preferred buffer duration in seconds before playback begins. Defaults to 60. + /// Works on iOS/tvOS 10+ when `bufferingStrategy` is `.playWhenPreferredBufferDurationFull`. + public var preferredBufferDurationBeforePlayback = TimeInterval(60) + + /// Defines the preferred size of the forward buffer for the underlying `AVPlayerItem`. + /// Works on iOS/tvOS 10+, default is 0, which lets `AVPlayer` decide. + public var preferredForwardBufferDuration = TimeInterval(0) + + @objc(remoteCommandsEnabled) + public var objc_remoteCommandsEnabled: [Int] = [AudioPlayerRemoteCommand.changePlaybackPosition.rawValue, + AudioPlayerRemoteCommand.previousTrack.rawValue, + AudioPlayerRemoteCommand.playPause.rawValue, + AudioPlayerRemoteCommand.nextTrack.rawValue] { + didSet { + remoteCommandsEnabled = objc_remoteCommandsEnabled.map({ AudioPlayerRemoteCommand(rawValue: $0)! }) + } + } + + /// Defines which remote control commands should be enabled. Max shown on iOS is 3 commands. + public var remoteCommandsEnabled: [AudioPlayerRemoteCommand] = [.changePlaybackPosition, .previousTrack, .playPause, .nextTrack] { + didSet { + if #available(OSX 10.12.2, *) { + unregisterRemoteControlCommands(oldValue) + registerRemoteControlCommands() + } + } + } + + /// Defines how to behave when the user is seeking through the lockscreen or the control center. + /// + /// - multiplyRate: Multiples the rate by a factor. + /// - changeTime: Changes the current position by adding/substracting a time interval. + public enum SeekingBehavior { + case multiplyRate(Float) + case changeTime(every: TimeInterval, delta: TimeInterval) + + func handleSeekingStart(player: AudioPlayer, forward: Bool) { + switch self { + case .multiplyRate(let rateMultiplier): + if forward { + player.rate = player.rate * rateMultiplier + } else { + player.rate = -(player.rate * rateMultiplier) + } + + case .changeTime: + player.seekEventProducer.isBackward = !forward + player.seekEventProducer.startProducingEvents() + } + } + + func handleSeekingEnd(player: AudioPlayer, forward: Bool) { + switch self { + case .multiplyRate(let rateMultiplier): + if forward { + player.rate = player.rate / rateMultiplier + } else { + player.rate = -(player.rate / rateMultiplier) + } + + case .changeTime: + player.seekEventProducer.stopProducingEvents() + } + } + } + + /// Defines the rate behavior of the player when the backward/forward buttons are pressed. Default value + /// is `multiplyRate(2)`. + public var seekingBehavior = SeekingBehavior.multiplyRate(2) { + didSet { + if case .changeTime(let timerInterval, _) = seekingBehavior { + seekEventProducer.intervalBetweenEvents = timerInterval + } + } + } + + // MARK: Readonly properties + + /// The current state of the player. + public internal(set) var state = AudioPlayerState.stopped { + didSet { + updateNowPlayingInfoCenter() + + if state != oldValue { + if [.buffering, .waitingForConnection].contains(oldValue) { + backgroundHandler.endBackgroundTask() + } + if [.buffering, .waitingForConnection].contains(state) { + backgroundHandler.beginBackgroundTask() + } + delegate?.audioPlayer?(self, didChangeStateFrom: oldValue, to: state) + } + } + } + + /// The current quality being played. + public internal(set) var currentQuality: AudioQuality + + // MARK: Private properties + + /// A SeekOperation which will be executed once currentItem is ready to play. + var queuedSeek: SeekOperation? + + /// A boolean value indicating whether the player has been paused because of a system interruption. + var pausedForInterruption = false + + /// A boolean value indicating if quality is being changed. It's necessary for the interruption count to not be + /// incremented while new quality is buffering. + var qualityIsBeingChanged = false + + /// The state before the player went into .Buffering. It helps to know whether to restart or not the player. + var stateBeforeBuffering: AudioPlayerState? + + /// The state of the player when the connection was lost + var stateWhenConnectionLost: AudioPlayerState? + + /// Convenience for checking whether currentItem being played is an offline resource. + var currentItemIsOffline: Bool { + get { + return currentItem?.soundURLs[currentQuality]?.ap_isOfflineURL ?? false + } + } + + /// Convenience for checking if platform is currently online + var isOnline: Bool { + get { + return reachability.connection != Reachability.Connection.unavailable + } + } + + // MARK: Initialization + + /// Initializes a new AudioPlayer. + public override init() { + currentQuality = defaultQuality + super.init() + + playerEventProducer.eventListener = self + networkEventProducer.eventListener = self + audioItemEventProducer.eventListener = self + qualityAdjustmentEventProducer.eventListener = self + } + + /// Deinitializes the AudioPlayer. On deinit, the player will simply stop playing anything it was previously + /// playing. + deinit { + networkEventProducer.stopProducingEvents() + stop() + } + + // MARK: Utility methods + + /// Updates the MPNowPlayingInfoCenter with current item's info. + func updateNowPlayingInfoCenter() { + #if os(iOS) || os(tvOS) + KDEDebug("updateNowPlayingInfoCenter") + if let item = currentItem { + setRemoteControlCommandsEnabled(true) + MPNowPlayingInfoCenter.default().ap_update( + with: item, + duration: currentItemDuration, + progression: currentItemProgression, + playbackRate: player?.rate ?? 0) + } else { + setRemoteControlCommandsEnabled(false) + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + } + #endif + } + + /// Enables or disables the `AVAudioSession` and sets the right category. + /// + /// - Parameter active: A boolean value indicating whether the audio session should be set to active or not. + func setAudioSession(active: Bool) { + #if os(iOS) || os(tvOS) + do { + if (active) { + try AVAudioSession.sharedInstance().setCategory(sessionCategory, mode: sessionMode) + } + try AVAudioSession.sharedInstance().setActive(active) + KDEDebug("AVAudioSession setActive(\(active))") + } catch { + KDEDebug("AVAudioSession setActive(\(active)) Error: \(error.localizedDescription)") + } + #endif + } + + // MARK: Public computed properties + + /// Boolean value indicating whether the player should resume playing (after buffering) + var shouldResumePlaying: Bool { + return !state.isPaused && + (stateWhenConnectionLost.map { !$0.isPaused } ?? true) && + (stateBeforeBuffering.map { !$0.isPaused } ?? true) + } + + // MARK: Retrying + + /// This will retry to play current item and seek back at the correct position if possible (or enabled). If not, + /// it'll just play the next item in queue. + func retryOrPlayNext() { + guard !state.isPlaying else { + retryEventProducer.stopProducingEvents() + return + } + + let cip = currentItemProgression + let ci = currentItem + currentItem = ci + if let cip = cip { + //We can't call self.seek(to:) in here since the player is new + //and `cip` is probably not in the seekableTimeRanges. + player?.seek(to: CMTime(timeInterval: cip)) + } + } + + /// Updates the current player based on the current buffering strategy. + /// Only has an effect on iOS 10+, tvOS 10+ and macOS 10.12+ + func updatePlayerForBufferingStrategy() { + player?.automaticallyWaitsToMinimizeStalling = self.bufferingStrategy != .playWhenBufferNotEmpty + } + + /// Updates a given player item based on the `preferredForwardBufferDuration` set. + /// Only has an effect on iOS 10+, tvOS 10+ and macOS 10.12+ + func updatePlayerItemForBufferingStrategy(_ playerItem: AVPlayerItem) { + //Nothing strategy-specific yet + playerItem.preferredForwardBufferDuration = self.preferredForwardBufferDuration + } +} + +extension AudioPlayer: EventListener { + /// The implementation of `EventListener`. It handles network events, player events, audio item events, quality + /// adjustment events, retry events and seek events. + /// + /// - Parameters: + /// - event: The event. + /// - eventProducer: The producer of the event. + func onEvent(_ event: Event, generetedBy eventProducer: EventProducer) { + if let event = event as? NetworkEventProducer.NetworkEvent { + handleNetworkEvent(from: eventProducer, with: event) + } else if let event = event as? PlayerEventProducer.PlayerEvent { + handlePlayerEvent(from: eventProducer, with: event) + } else if let event = event as? AudioItemEventProducer.AudioItemEvent { + handleAudioItemEvent(from: eventProducer, with: event) + } else if let event = event as? QualityAdjustmentEventProducer.QualityAdjustmentEvent { + handleQualityEvent(from: eventProducer, with: event) + } else if let event = event as? RetryEventProducer.RetryEvent { + handleRetryEvent(from: eventProducer, with: event) + } else if let event = event as? SeekEventProducer.SeekEvent { + handleSeekEvent(from: eventProducer, with: event) + } + } +} diff --git a/ios/Classes/player/AudioPlayerBufferingStrategy.swift b/ios/Classes/player/AudioPlayerBufferingStrategy.swift new file mode 100644 index 0000000..14c6aa3 --- /dev/null +++ b/ios/Classes/player/AudioPlayerBufferingStrategy.swift @@ -0,0 +1,23 @@ +// +// AudioPlayerBufferingStrategy.swift +// AudioPlayer +// +// Created by Daniel Freiling on 10/05/2017. +// Copyright © 2017 Kevin Delannoy. All rights reserved. +// + +import Foundation + +/// Represents the strategy used for buffering of items before playback is started +@objc public enum AudioPlayerBufferingStrategy: Int { + /// Uses the default AVPlayer buffering strategy, which buffers very aggressively before starting playback. + /// This often leads to start of playback being delayed more than necessary. + case defaultBuffering = 0 + + /// Uses a strategy better at quickly starting playback. Duration to buffer before playback is customizable through + /// the `preferredBufferDurationBeforePlayback` variable. Requires iOS/tvOS 10+ to have any effect. + case playWhenPreferredBufferDurationFull = 1 + + /// Uses a strategy that simply starts playback whenever the AVPlayerItem buffer is non-empty. Requires iOS/tvOS 10+ to have any effect. + case playWhenBufferNotEmpty = 2 +} diff --git a/ios/Classes/player/AudioPlayerDelegate.swift b/ios/Classes/player/AudioPlayerDelegate.swift new file mode 100644 index 0000000..6b68720 --- /dev/null +++ b/ios/Classes/player/AudioPlayerDelegate.swift @@ -0,0 +1,80 @@ +// +// AudioPlayerDelegate.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 09/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import AVFoundation + +/// This protocol contains helpful methods to alert you of specific events. If you want to be notified about those +/// events, you will have to set a delegate to your `audioPlayer` instance. +@objc public protocol AudioPlayerDelegate { + /// This method is called when the audio player changes its state. A fresh created audioPlayer starts in `.stopped` + /// mode. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - from: The state before any changes. + /// - state: The new state. + @objc optional func audioPlayer(_ audioPlayer: AudioPlayer, didChangeStateFrom from: AudioPlayerState, to state: AudioPlayerState) + + /// This method is called to ensure an item should be played or not. Default implementation returns `true`. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - item: The item that is waiting to be played. + /// - Returns: A boolean value indicating whether the player should start playing the item or not. + func audioPlayer(_ audioPlayer: AudioPlayer, shouldStartPlaying item: AudioItem) -> Bool + + /// This method is called when the audio player is about to start playing a new item. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - item: The item that is about to start being played. + @objc optional func audioPlayer(_ audioPlayer: AudioPlayer, willStartPlaying item: AudioItem) + + /// This method is called when the audio player finishes playing an item to its end. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - item: The item that finished playing + @objc optional func audioPlayer(_ audioPlayer: AudioPlayer, finishedPlaying item: AudioItem) + + /// This method is called a regular time interval while playing. It notifies the delegate that the current playing + /// progression changed. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - time: The current progression. + /// - percentageRead: The percentage of the file that has been read. It's a Float value between 0 & 100 so that + /// you can easily update an `UISlider` for example. + @objc optional func audioPlayer(_ audioPlayer: AudioPlayer, didUpdateProgressionTo time: TimeInterval, percentageRead: Float) + + /// This method gets called when the current item duration has been found. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - duration: Current item's duration. + /// - item: Current item. + @objc optional func audioPlayer(_ audioPlayer: AudioPlayer, didFindDuration duration: TimeInterval, for item: AudioItem) + + /// This methods gets called before duration gets updated with discovered metadata. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - item: Current item. + /// - data: Found metadata. + @objc optional func audioPlayer(_ audioPlayer: AudioPlayer, didUpdateEmptyMetadataOn item: AudioItem, withData data: [AVMetadataItem]) + + /// This method gets called while the audio player is loading the file (over the network or locally). It lets the + /// delegate know what time range has already been loaded. + /// + /// - Parameters: + /// - audioPlayer: The audio player. + /// - earliest: The earliest point of the loaded time range. + /// - latest: The latest point of the loaded time range. + /// - item: Current item. + @objc optional func audioPlayer(_ audioPlayer: AudioPlayer, didLoadEarliest earliest: TimeInterval, latest: TimeInterval, for item: AudioItem) +} diff --git a/ios/Classes/player/AudioPlayerMode.swift b/ios/Classes/player/AudioPlayerMode.swift new file mode 100644 index 0000000..2401496 --- /dev/null +++ b/ios/Classes/player/AudioPlayerMode.swift @@ -0,0 +1,35 @@ +// +// AudioPlayerMode.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 19/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +/// Represents the mode in which the player should play. Modes can be used as masks so that you can play in `.shuffle` +/// mode and still `.repeatAll`. +public struct AudioPlayerMode: OptionSet { + /// The raw value describing the mode. + public let rawValue: UInt + + /// Initializes an `AudioPlayerMode` from a `rawValue`. + /// + /// - Parameter rawValue: The raw value describing the mode. + public init(rawValue: UInt) { + self.rawValue = rawValue + } + + /// In this mode, player's queue will be played as given. + public static let normal = AudioPlayerMode([]) + + /// In this mode, player's queue is shuffled randomly. + public static let shuffle = AudioPlayerMode(rawValue: 0b001) + + /// In this mode, the player will continuously play the same item over and over. + public static let `repeat` = AudioPlayerMode(rawValue: 0b010) + + /// In this mode, the player will continuously play the same queue over and over. + public static let repeatAll = AudioPlayerMode(rawValue: 0b100) +} diff --git a/ios/Classes/player/AudioPlayerState.swift b/ios/Classes/player/AudioPlayerState.swift new file mode 100644 index 0000000..937ce74 --- /dev/null +++ b/ios/Classes/player/AudioPlayerState.swift @@ -0,0 +1,101 @@ +// +// AudioPlayerState.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 11/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +/// The possible errors an `AudioPlayer` can fail with. +/// +/// - maximumRetryCountHit: The player hit the maximum retry count. +/// - foundationError: The `AVPlayer` failed to play. +/// - itemNotConsideredPlayable: The current item that should be played is considered unplayable. +/// - noItemsConsideredPlayable: The queue doesn't contain any item that is considered playable. +public enum AudioPlayerError: Error { + case maximumRetryCountHit + case foundationError(Error) + case itemNotConsideredPlayable + case noItemsConsideredPlayable +} + +/// `AudioPlayerState` defines 4 state an `AudioPlayer` instance can be in. +/// +/// - buffering: The player is buffering data before playing them. +/// - playing: The player is playing. +/// - paused: The player is paused. +/// - stopped: The player is stopped. +/// - waitingForConnection: The player is waiting for internet connection. +/// - failed: An error occured. It contains AVPlayer's error if any. +@objc public enum AudioPlayerState: Int { + case buffering = 0 + case playing = 1 + case paused = 2 + case stopped = 3 + case waitingForConnection = 4 + case failed = 5 + + /// A boolean value indicating is self = `buffering`. + var isBuffering: Bool { + if case .buffering = self { + return true + } + return false + } + + /// A boolean value indicating is self = `playing`. + var isPlaying: Bool { + if case .playing = self { + return true + } + return false + } + + /// A boolean value indicating is self = `paused`. + var isPaused: Bool { + if case .paused = self { + return true + } + return false + } + + /// A boolean value indicating is self = `stopped`. + var isStopped: Bool { + if case .stopped = self { + return true + } + return false + } + + /// A boolean value indicating is self = `waitingForConnection`. + var isWaitingForConnection: Bool { + if case .waitingForConnection = self { + return true + } + return false + } + + /// A boolean value indicating is self = `failed`. + var isFailed: Bool { + if case .failed = self { + return true + } + return false + } +} + +// MARK: - Equatable + +extension AudioPlayerState: Equatable {} + +public func == (lhs: AudioPlayerState, rhs: AudioPlayerState) -> Bool { + if (lhs.isBuffering && rhs.isBuffering) || (lhs.isPlaying && rhs.isPlaying) || + (lhs.isPaused && rhs.isPaused) || (lhs.isStopped && rhs.isStopped) || + (lhs.isWaitingForConnection && rhs.isWaitingForConnection) || + (lhs.isFailed && rhs.isFailed) { + return true + } + return false +} diff --git a/ios/Classes/player/extensions/AudioPlayer+AudioItemEvent.swift b/ios/Classes/player/extensions/AudioPlayer+AudioItemEvent.swift new file mode 100644 index 0000000..e49b1f3 --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+AudioItemEvent.swift @@ -0,0 +1,18 @@ +// +// AudioPlayer+AudioItemEvent.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 03/04/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +extension AudioPlayer { + /// Handles audio item events. + /// + /// - Parameters: + /// - producer: The event producer that generated the audio item event. + /// - event: The audio item event. + func handleAudioItemEvent(from producer: EventProducer, with event: AudioItemEventProducer.AudioItemEvent) { + updateNowPlayingInfoCenter() + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+Control.swift b/ios/Classes/player/extensions/AudioPlayer+Control.swift new file mode 100644 index 0000000..53311c3 --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+Control.swift @@ -0,0 +1,221 @@ +// +// AudioPlayer+Control.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 29/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import CoreMedia +#if os(iOS) || os(tvOS) + import UIKit +#endif + +extension AudioPlayer { + /// Resumes the player. + public func resume() { + //Ensure pause flag is no longer set + pausedForInterruption = false + + //Pause initiates a background task, end it on resume + backgroundHandler.endBackgroundTask() + + //Ensures the audio session is active + setAudioSession(active: true) + + player?.rate = rate + + //We don't wan't to change the state to Playing in case it's Buffering. That + //would be a lie. + //If streaming go to buffering state, allowing non-default buffering strategies to work + if !state.isPlaying && !state.isBuffering { + state = currentItemIsOffline ? .playing : .buffering + } + + retryEventProducer.startProducingEvents() + } + + /// Pauses the player. + public func pause() { + //We ensure the player actually pauses + player?.rate = 0 + state = .paused + + retryEventProducer.stopProducingEvents() + + //Let's begin a background task for the player to keep buffering if the app is in + //background. This will mimic the default behavior of `AVPlayer` when pausing while the + //app is in foreground. + backgroundHandler.beginBackgroundTask() + } + + /// Starts playing the current item immediately. Works on iOS/tvOS 10+ and macOS 10.12+ + func playImmediately() { + //NOTE: No need to do anything if we're already playing + guard let player = player, player.timeControlStatus != .playing else { + return + } + self.state = .playing + player.playImmediately(atRate: rate) + + retryEventProducer.stopProducingEvents() + } + + /// Plays previous item in the queue or rewind current item. + public func previous() { + if let previousItem = queue?.previousItem() { + currentItem = previousItem + } else { + seek(to: 0) + } + } + + /// Plays next item in the queue. + public func next() { + if let nextItem = queue?.nextItem() { + currentItem = nextItem + } + } + + /// Plays the next item in the queue and if there isn't, the player will stop. + public func nextOrStop() { + if let nextItem = queue?.nextItem() { + currentItem = nextItem + } else { + stop() + } + } + + /// Stops the player and clear the queue. + public func stop() { + if (state == .stopped) { + return + } + retryEventProducer.stopProducingEvents() + + if let _ = player { + player?.pause() + player?.replaceCurrentItem(with: nil) + player = nil + } + + if let _ = currentItem { + currentItem = nil + } + if let _ = queue { + queue = nil + } + + state = .stopped + + // Fix: Some AVURLAssets may take a short while to seize I/O ops. + // We therefore delay ending the AudioSession + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: { [weak self] in + guard let self = self else { return } + self.setAudioSession(active: false) + }) + } + + /// Seeks to a specific time. + /// + /// - Parameters: + /// - time: The time to seek to. + /// - byAdaptingTimeToFitSeekableRanges: A boolean value indicating whether the time should be adapted to current + /// seekable ranges in order to be bufferless. + /// - toleranceBefore: The tolerance allowed before time. + /// - toleranceAfter: The tolerance allowed after time. + /// - completionHandler: The optional callback that gets executed upon completion with a boolean param indicating + /// if the operation has finished. + public func seek(to time: TimeInterval, + byAdaptingTimeToFitSeekableRanges: Bool = false, + toleranceBefore: CMTime = CMTime.positiveInfinity, + toleranceAfter: CMTime = CMTime.positiveInfinity, + completionHandler: ((Bool) -> Void)? = nil) { + KDEDebug("seek to \(time)") + guard let earliest = currentItemSeekableRange?.earliest, + let latest = currentItemSeekableRange?.latest else { + //In case we don't have a valid `seekableRange`, although this *shouldn't* happen + //let's just call `AVPlayer.seek(to:)` with given values. + seekSafely(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter, + completionHandler: completionHandler) + return + } + + if !byAdaptingTimeToFitSeekableRanges || (time >= earliest && time <= latest) { + //Time is in seekable range, there's no problem here. + seekSafely(to: time, toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter, + completionHandler: completionHandler) + } else if time < earliest { + //Time is before seekable start, so just move to the most early position as possible. + seekToSeekableRangeStart(padding: 1, completionHandler: completionHandler) + } else if time > latest { + //Time is larger than possibly, so just move forward as far as possible. + seekToSeekableRangeEnd(padding: 1, completionHandler: completionHandler) + } + } + + /// Seeks backwards as far as possible. + /// + /// - Parameter padding: The padding to apply if any. + /// - completionHandler: The optional callback that gets executed upon completion with a boolean param indicating + /// if the operation has finished. + public func seekToSeekableRangeStart(padding: TimeInterval, completionHandler: ((Bool) -> Void)? = nil) { + guard let range = currentItemSeekableRange else { + completionHandler?(false) + return + } + let position = min(range.latest, range.earliest + padding) + seekSafely(to: position, completionHandler: completionHandler) + } + + /// Seeks forward as far as possible. + /// + /// - Parameter padding: The padding to apply if any. + /// - completionHandler: The optional callback that gets executed upon completion with a boolean param indicating + /// if the operation has finished. + public func seekToSeekableRangeEnd(padding: TimeInterval, completionHandler: ((Bool) -> Void)? = nil) { + guard let range = currentItemSeekableRange else { + completionHandler?(false) + return + } + let position = max(range.earliest, range.latest - padding) + seekSafely(to: position, completionHandler: completionHandler) + } + + public func seekToRelativeTime(_ relativeTime: TimeInterval, completionHandler: ((Bool) -> Void)? = nil) { + guard let currentTime = player?.currentTime(), + currentTime.seconds.isFinite else { + completionHandler?(false) + return + } + let seekToAbsoluteTime = max(currentTime.seconds + relativeTime, 0) + seek(to: seekToAbsoluteTime, byAdaptingTimeToFitSeekableRanges: false, toleranceBefore: CMTime.positiveInfinity, toleranceAfter: CMTime.positiveInfinity, completionHandler: completionHandler) + } +} + +extension AudioPlayer { + + fileprivate func seekSafely(to time: TimeInterval, + toleranceBefore: CMTime = CMTime.positiveInfinity, + toleranceAfter: CMTime = CMTime.positiveInfinity, + completionHandler: ((Bool) -> Void)?) { + if (player?.currentItem?.status == .readyToPlay) { + KDEDebug("seekSafely: seek to \(time)") + player?.seek(to: CMTime(timeInterval: time), toleranceBefore: toleranceBefore, toleranceAfter: toleranceAfter) { [weak self] finished in + completionHandler?(finished) + self?.updateNowPlayingInfoCenter() + } + } else if (player?.currentItem?.status == .unknown) { + KDEDebug("seekSafely: currentItem not loaded yet, queue the seek for when it's ready") + // status is unknown, queue the seek for when status changes to ready + queuedSeek = SeekOperation(time: time, + toleranceBefore: toleranceBefore, + toleranceAfter: toleranceAfter, + completionHandler: completionHandler) + } else { + KDEDebug("seekSafely: currentItem is failed, cannot seek") + // seek is not possible + completionHandler?(false) + } + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+CurrentItem.swift b/ios/Classes/player/extensions/AudioPlayer+CurrentItem.swift new file mode 100644 index 0000000..d0b11a0 --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+CurrentItem.swift @@ -0,0 +1,86 @@ +// +// AudioPlayer+CurrentItem.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 29/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +@objcMembers public class TimeRange: NSObject { + public var earliest: TimeInterval + public var latest: TimeInterval + + public init(earliest: TimeInterval, latest: TimeInterval) { + self.earliest = earliest + self.latest = latest + } +} + +extension AudioPlayer { + /// The current item progression or nil if no item. + public var currentItemProgression: TimeInterval? { + return player?.currentItem?.currentTime().ap_timeIntervalValue + } + + /// The current item duration or nil if no item or unknown duration. + public var currentItemDuration: TimeInterval? { + return player?.currentItem?.duration.ap_timeIntervalValue + } + + @objc(currentItemProgression) + public var objc_currentTime: NSNumber? { + if let currentItemProgression = currentItemProgression { + return currentItemProgression as NSNumber + } else { + return nil + } + } + + @objc(currentItemDuration) + public var objc_currentDuration: NSNumber? { + if let currentItemDuration = currentItemDuration { + return currentItemDuration as NSNumber + } else { + return nil + } + } + + /// The current seekable range. + public var currentItemSeekableRange: TimeRange? { + let range = player?.currentItem?.seekableTimeRanges.last?.timeRangeValue + if let start = range?.start.ap_timeIntervalValue, let end = range?.end.ap_timeIntervalValue { + return TimeRange(earliest: start, latest: end) + } + if let currentItemProgression = currentItemProgression { + // if there is no start and end point of seekable range + // return the current time, so no seeking possible + return TimeRange(earliest: currentItemProgression, latest: currentItemProgression) + } + // cannot seek at all, so return nil + return nil + } + + /// The current loaded range. + public var currentItemLoadedRange: TimeRange? { + let range = player?.currentItem?.loadedTimeRanges.last?.timeRangeValue + if let start = range?.start.ap_timeIntervalValue, let end = range?.end.ap_timeIntervalValue { + return TimeRange(earliest: start, latest: end) + } + return nil + } + + public var currentItemLoadedAhead: TimeInterval? { + if let loadedRange = currentItemLoadedRange, + let currentTime = player?.currentTime(), + loadedRange.earliest <= currentTime.seconds { + return loadedRange.latest - currentTime.seconds + } + return nil + } + + public var currentItemIsReady: Bool { + return player?.currentItem?.status == .readyToPlay + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+NetworkEvent.swift b/ios/Classes/player/extensions/AudioPlayer+NetworkEvent.swift new file mode 100644 index 0000000..7a0196b --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+NetworkEvent.swift @@ -0,0 +1,57 @@ +// +// AudioPlayer+NetworkEvent.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 03/04/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +extension AudioPlayer { + /// Handles network events. + /// + /// - Parameters: + /// - producer: The event producer that generated the network event. + /// - event: The network event. + func handleNetworkEvent(from producer: EventProducer, with event: NetworkEventProducer.NetworkEvent) { + switch event { + case .connectionLost: + //Early exit if state prevents us to handle connection loss + guard currentItem != nil, !state.isWaitingForConnection else { + return + } + + //In case we're not playing offline file + if !currentItemIsOffline { + stateWhenConnectionLost = state + + if let currentItem = player?.currentItem, currentItem.isPlaybackBufferEmpty { + if case .playing = state { + qualityAdjustmentEventProducer.interruptionCount += 1 + } + + state = .waitingForConnection + } + } + + case .connectionRetrieved: + //Early exit if connection wasn't lost during playing or `resumeAfterConnectionLoss` + //isn't enabled. + guard let lossDate = networkEventProducer.connectionLossDate, + let stateWhenLost = stateWhenConnectionLost, resumeAfterConnectionLoss else { + return + } + + let isAllowedToRestart = lossDate.timeIntervalSinceNow < maximumConnectionLossTime + let wasPlayingBeforeLoss = !stateWhenLost.isStopped && !stateWhenLost.isPaused + + if isAllowedToRestart && wasPlayingBeforeLoss { + retryOrPlayNext() + } + + stateWhenConnectionLost = nil + + case .networkChanged: + break + } + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+PlayerEvent.swift b/ios/Classes/player/extensions/AudioPlayer+PlayerEvent.swift new file mode 100644 index 0000000..a468de3 --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+PlayerEvent.swift @@ -0,0 +1,189 @@ +// +// AudioPlayer+PlayerEvent.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 03/04/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import CoreMedia + +extension AudioPlayer { + /// Handles player events. + /// + /// - Parameters: + /// - producer: The event producer that generated the player event. + /// - event: The player event. + func handlePlayerEvent(from producer: EventProducer, with event: PlayerEventProducer.PlayerEvent) { + switch event { + case .endedPlaying(let error): + if !currentItemIsOffline, + isInternetConnectionError(error) || (!isOnline && isEndedEarlyError(error)) { + // While playing online content we got an internet error or + // ended playing the item before it was finished while offline (likely also due to connection loss). + stateWhenConnectionLost = .playing + state = .waitingForConnection + } else if let error = error, !isEndedEarlyError(error) { + // Some unrecoverable error occured while playing, set failed state and + // let the retry handler try again if it's enabled. + state = .failed + failedError = error + + // We might have had a queuedSeek for this item when it failed + queuedSeek = nil + } else { + if let currentItem = self.currentItem { + delegate?.audioPlayer?(self, finishedPlaying: currentItem) + } + nextOrStop() + } + + case .interruptionBegan where state.isPlaying || state.isBuffering: + KDEDebug("Interruption Began - Pause!") + //We pause the player when an interruption is detected + backgroundHandler.beginBackgroundTask() + pausedForInterruption = true + pause() + + case .interruptionEnded(let shouldResume) where pausedForInterruption: + KDEDebug("Interruption Ended - shouldResume=\(shouldResume)") + if resumeAfterInterruption && shouldResume { + resume() + } + pausedForInterruption = false + backgroundHandler.endBackgroundTask() + + case .loadedDuration(let time): + if let currentItem = currentItem, let time = time.ap_timeIntervalValue { + updateNowPlayingInfoCenter() + delegate?.audioPlayer?(self, didFindDuration: time, for: currentItem) + } + + case .loadedMetadata(let metadata): + if let currentItem = currentItem, !metadata.isEmpty { + currentItem.parseMetadata(metadata) + delegate?.audioPlayer?(self, didUpdateEmptyMetadataOn: currentItem, withData: metadata) + } + + case .loadedMoreRange: + if let currentItem = currentItem, let currentItemLoadedRange = currentItemLoadedRange { + delegate?.audioPlayer?(self, didLoadEarliest: currentItemLoadedRange.earliest, latest: currentItemLoadedRange.latest, for: currentItem) + + // NOTE: We also play immediately if we are in playing state, + // as it does nothing if timeControlStatus is already .playing. + // After a seek we could be in .playing state, but not started yet because of default buffering strategy + // TODO: seek should set state = .buffering if applicable + + if bufferingStrategy == .playWhenPreferredBufferDurationFull, + state == .buffering || state == .playing, + let loadedAhead = currentItemLoadedAhead, + loadedAhead.isNormal, + loadedAhead >= self.preferredBufferDurationBeforePlayback { + playImmediately() + } + } + + case .progressed(let time): + if let currentItemProgression = time.ap_timeIntervalValue, let item = player?.currentItem, + item.status == .readyToPlay { + //This fixes the behavior where sometimes the `playbackLikelyToKeepUp` isn't + //changed even though it's playing (happens mostly at the first play though). + if state.isBuffering || state.isPaused { + if shouldResumePlaying { + stateBeforeBuffering = nil + state = .playing + player?.rate = rate + } else { + player?.rate = 0 + state = .paused + } + } + + //Then we can call the didUpdateProgressionTo: delegate method + let itemDuration = currentItemDuration ?? 0 + let percentage = (itemDuration > 0 ? Float(currentItemProgression / itemDuration) * 100 : 0) + delegate?.audioPlayer?(self, didUpdateProgressionTo: currentItemProgression, percentageRead: percentage) + } + + case .readyToPlay: + //There is enough data in the buffer + KDEDebug("readyToPlay") + if let seekOperation = queuedSeek { + seek(to: seekOperation.time, + byAdaptingTimeToFitSeekableRanges: seekOperation.adaptToFitSeekableRanges, + toleranceBefore: seekOperation.toleranceBefore, + toleranceAfter: seekOperation.toleranceAfter, + completionHandler: seekOperation.completionHandler) + queuedSeek = nil + } + + case .playbackLikelyToKeepUp: + //Current item has buffered enough to keep playing without interruption + KDEDebug("playbackLikelyToKeepUp - shouldResumePlaying? \(shouldResumePlaying)") + if shouldResumePlaying { + stateBeforeBuffering = nil + state = .playing + player?.rate = rate + playImmediately() + } else { + player?.rate = 0 + state = .paused + } + + preloadNextItemAsset() + + //TODO: where to start? + retryEventProducer.stopProducingEvents() + + case .routeChanged(let deviceDisconnected): + // When a route changes because a device got disconnected (e.g. unplugged headphones) + // the player can be paused. This interruption must respect interruptionBegan. + //TODO: Handle other reasons. + if deviceDisconnected, + let currentItemTimebase = player?.currentItem?.timebase, + CMTimebaseGetRate(currentItemTimebase) == 0 { + state = .paused + } + + case .sessionMessedUp: + #if os(iOS) || os(tvOS) + //We reenable the audio session directly in case we're in background + setAudioSession(active: true) + + //Aaaaand we: restart playing/go to next + state = .stopped + qualityAdjustmentEventProducer.interruptionCount += 1 + retryOrPlayNext() + #endif + + case .startedBuffering: + //The buffer is empty and player is loading + if case .playing = state, !qualityIsBeingChanged { + qualityAdjustmentEventProducer.interruptionCount += 1 + } + + stateBeforeBuffering = state + if isOnline || currentItemIsOffline { + state = .buffering + } else { + state = .waitingForConnection + } + + default: + break + } + } +} + +//TODO: Refactor to better location +func isInternetConnectionError(_ error: Error?) -> Bool { + guard let urlErr = error as? URLError else { + return false + } + return urlErr.code == URLError.Code.notConnectedToInternet + || urlErr.code == URLError.Code.networkConnectionLost +} + +func isEndedEarlyError(_ error: Error?) -> Bool { + return error as? EndedError == EndedError.ItemEndedEarly +} diff --git a/ios/Classes/player/extensions/AudioPlayer+Preload.swift b/ios/Classes/player/extensions/AudioPlayer+Preload.swift new file mode 100644 index 0000000..05062b4 --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+Preload.swift @@ -0,0 +1,98 @@ +// +// AudioPlayer+Preload.swift +// AudioPlayer +// +// Created by Daniel Dam Freiling on 11/05/2017. +// Copyright © 2017 Kevin Delannoy. All rights reserved. +// + +import Foundation +import AVFoundation + +extension AudioPlayer { + + private static let assetPreloadKeys = ["tracks", "playable"] + + public func clearAssetCache() { + cachedAssets = [:] + } + + func getAVURLAsset(forUrl: URL) -> AVURLAsset { + if let asset = cachedAssets[forUrl], + !assetHasFailed(asset) { + KDEDebug("getAVURLAsset: cached") + return asset + } else { + ///See options: https://developer.apple.com/reference/avfoundation/avurlasset/initialization_options + let asset = AVURLAsset(url: forUrl) + cachedAssets[forUrl] = asset + KDEDebug("getAVURLAsset: new") + return asset + } + } + + func getAVPlayerItem(forUrl: URL) -> AVPlayerItem { + let asset = getAVURLAsset(forUrl: forUrl) + let playerItem = AVPlayerItem(asset: asset, automaticallyLoadedAssetKeys: AudioPlayer.assetPreloadKeys) + playerItem.audioTimePitchAlgorithm = self.timePitchAlgorithm + playerItem.preferredForwardBufferDuration = self.preferredForwardBufferDuration + + return playerItem + } + + func preloadItemAsset(asset: AVURLAsset, onComplete: @escaping (AVURLAsset?) -> Void) { + asset.loadValuesAsynchronously(forKeys: AudioPlayer.assetPreloadKeys) { [weak self] in + guard let this = self, this.assetPreloadKeysAreLoaded(asset: asset) else { + self?.cachedAssets.removeValue(forKey: asset.url) + onComplete(nil) + return + } + onComplete(asset) + } + } + + func preloadNextItemAsset() { + guard let queue = queue, hasNext else { + return + } + let nextPosition = queue.nextPosition, + item = queue.items[nextPosition], + urlInfo = item.highestQualityURL + if cachedAssets[urlInfo.url] != nil { + // Next item has been or is already being preloaded + return + } + KDEDebug("preload queue idx: \(nextPosition)") + let asset = getAVURLAsset(forUrl: urlInfo.url) + preloadItemAsset(asset: asset) { asset in + if (asset == nil) { + KDEDebug("ERROR preloading queue idx: \(nextPosition)") + } else { + KDEDebug("preloaded queue idx: \(nextPosition)!") + } + } + } + + private func assetHasFailed(_ asset: AVURLAsset) -> Bool { + for key in AudioPlayer.assetPreloadKeys { + var error: NSError? + let result = asset.statusOfValue(forKey: key, error: &error) + if (result == .failed || result == .cancelled || error != nil) { + return true + } + } + return false + } + + private func assetPreloadKeysAreLoaded(asset: AVURLAsset) -> Bool { + for key in AudioPlayer.assetPreloadKeys { + var error: NSError? + let result = asset.statusOfValue(forKey: key, error: &error) + if (result != .loaded || error != nil) { + KDEDebug("AVAsset failed to load key '\(key)': (\(String(describing: result))) \(error?.localizedDescription ?? "")") + return false + } + } + return true + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+QualityAdjustmentEvent.swift b/ios/Classes/player/extensions/AudioPlayer+QualityAdjustmentEvent.swift new file mode 100644 index 0000000..4546d3a --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+QualityAdjustmentEvent.swift @@ -0,0 +1,62 @@ +// +// AudioPlayer+QualityAdjustmentEvent.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 03/04/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import AVFoundation + +extension AudioPlayer { + /// Handles quality adjustment events. + /// + /// - Parameters: + /// - producer: The event producer that generated the quality adjustment event. + /// - event: The quality adjustment event. + func handleQualityEvent(from producer: EventProducer, + with event: QualityAdjustmentEventProducer.QualityAdjustmentEvent) { + //Early exit if user doesn't want to adjust quality + guard adjustQualityAutomatically else { + return + } + + switch event { + case .goDown: + guard let quality = AudioQuality(rawValue: currentQuality.rawValue - 1) else { + return + } + changeQuality(to: quality) + + case .goUp: + guard let quality = AudioQuality(rawValue: currentQuality.rawValue + 1) else { + return + } + changeQuality(to: quality) + } + } + + /// Changes quality of the stream if possible. + /// + /// - Parameter newQuality: The new quality. + private func changeQuality(to newQuality: AudioQuality) { + guard let url = currentItem?.soundURLs[newQuality] else { + return + } + + let cip = currentItemProgression + let item = AVPlayerItem(url: url) + self.updatePlayerItemForBufferingStrategy(item) + + qualityIsBeingChanged = true + player?.replaceCurrentItem(with: item) + if let cip = cip { + //We can't call self.seek(to:) in here since the player is loading a new + //item and `cip` is probably not in the seekableTimeRanges. + player?.seek(to: CMTime(timeInterval: cip)) + } + qualityIsBeingChanged = false + + currentQuality = newQuality + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+Queue.swift b/ios/Classes/player/extensions/AudioPlayer+Queue.swift new file mode 100644 index 0000000..1dfc3e3 --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+Queue.swift @@ -0,0 +1,112 @@ +// +// AudioPlayer+Queue.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 29/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +extension AudioPlayer { + /// The items in the queue if any. + public var items: [AudioItem]? { + return queue?.queue + } + + /// The current item index in queue. + public var currentItemIndexInQueue: Int? { + return currentItem.flatMap { queue?.items.firstIndex(of: $0) } + } + + /// A boolean value indicating whether there is a next item to play or not. + public var hasNext: Bool { + return queue?.hasNextItem ?? false + } + + /// A boolean value indicating whether there is a previous item to play or not. + public var hasPrevious: Bool { + return queue?.hasPreviousItem ?? false + } + + /// Plays an item. + /// + /// - Parameter item: The item to play. + public func play(item: AudioItem) { + play(items: [item]) + } + + /// Creates a queue according to the current mode and plays it. + /// + /// - Parameters: + /// - items: The items to play. + /// - index: The index to start the player with. + public func play(items: [AudioItem], startAtIndex index: Int = 0) { + guard !items.isEmpty, 0 <= index, index < items.count else { + KDEDebug("invalid arguments for play(), items.count=\(items.count), index=\(index)") + stop() + queue = nil + return + } + //Remove cached assets that are not in the new playlist + if (cachedAssets.count > 0) { + let newUrls = items.flatMap { $0.soundURLs.map { $0.value } } + for (url, _) in cachedAssets { + if !newUrls.contains(url) { + cachedAssets.removeValue(forKey: url) + KDEDebug("Removed from cached assets: \(url.absoluteURL)") + } + } + } + //Setup the new queue + queue = AudioItemQueue(items: items, mode: mode) + queue?.delegate = self + if let realIndex = queue?.queue.firstIndex(of: items[index]) { + queue?.nextPosition = realIndex + } + //Set the new currentItem + currentItem = queue?.nextItem() + } + + /// Adds an item at the end of the queue. If queue is empty and player isn't playing, the behaviour will be similar + /// to `play(item:)`. + /// + /// - Parameter item: The item to add. + public func add(item: AudioItem) { + add(items: [item]) + } + + /// Adds items at the end of the queue. If the queue is empty and player isn't playing, the behaviour will be + /// similar to `play(items:)`. + /// + /// - Parameter items: The items to add. + public func add(items: [AudioItem]) { + if let queue = queue { + queue.add(items: items) + } else { + play(items: items) + } + } + + /// Removes an item at a specific index in the queue. + /// + /// - Parameter index: The index of the item to remove. + public func removeItem(at index: Int) { + if let item = queue?.queue[index] { + for urlInfo in item.soundURLs { + cachedAssets.removeValue(forKey: urlInfo.value) + } + queue?.remove(at: index) + } + } +} + +extension AudioPlayer: AudioItemQueueDelegate { + /// Returns a boolean value indicating whether an item should be consider playable in the queue. + /// + /// - Parameters: + /// - queue: The queue. + /// - item: The item we ask the information for. + /// - Returns: A boolean value indicating whether an item should be consider playable in the queue. + func audioItemQueue(_ queue: AudioItemQueue, shouldConsiderItem item: AudioItem) -> Bool { + return delegate?.audioPlayer(self, shouldStartPlaying: item) ?? true + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+RemoteControl.swift b/ios/Classes/player/extensions/AudioPlayer+RemoteControl.swift new file mode 100644 index 0000000..6b79ee0 --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+RemoteControl.swift @@ -0,0 +1,248 @@ +// +// AudioPlayer+RemoteControl.swift +// AudioPlayer +// +// Created by Daniel Dam Freiling on 15/05/2017. +// Copyright © 2017 Kevin Delannoy. All rights reserved. +// + +import Foundation +import MediaPlayer + +// RemoteControls TODO: +// - delegate callbacks for like/dislike/rate/bookmark commands +// - test shuffle/repeat mode commands + +/// Enum for AudioPlayer remote commands. Saves clients from having to provide MPRemoteCommand references. +/// Some commands may not occupy one of the command slots or can collapse with others into the menu button. +/// Unless stated otherwise these commands can be assumed to be available from iOS 7.1, tvOS 9 and OSX 10.12.1 +@objc public enum AudioPlayerRemoteCommand: Int { + /// This command encompasses play, pause and playPause commands + case playPause = 0 + case stop = 1 + case nextTrack = 2 + case previousTrack = 3 + case skipForward = 4 + case skipBackward = 5 + case seekForward = 6 + case seekBackward = 7 + case changePlaybackRate = 8 + /// Available from iOS 9.1 and tvOS 9.1 + case changePlaybackPosition = 9 + + //TODO: delegate callbacks for these commands + case rate = 10 + case like = 11 + case dislike = 12 + case bookmark = 13 + + /// Available from iOS 10 tvOS 10 + case changeRepeatMode = 14 + /// Available from iOS 10 and tvOS 10 + case changeShuffleMode = 15 +} + +@available(OSX 10.12.2, *) +extension AudioPlayer { + + /// Get or set the preferred intervals for skip forward and backward remote control commands. + /// Currently does not support a different interval for forward or backward. + public var remoteControlSkipIntervals: [NSNumber] { + get { + return MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals + } + set { + let remote = MPRemoteCommandCenter.shared() + remote.skipForwardCommand.preferredIntervals = newValue + remote.skipBackwardCommand.preferredIntervals = newValue + } + } + + /// Get or set the supported playback rates for the changePlaybackRate command + public var remoteControlSupportedPlaybackRates: [NSNumber] { + get { + return MPRemoteCommandCenter.shared().changePlaybackRateCommand.supportedPlaybackRates + } + set { + MPRemoteCommandCenter.shared().changePlaybackRateCommand.supportedPlaybackRates = newValue + } + } + + func unregisterRemoteControlCommands(_ cmds: [AudioPlayerRemoteCommand]) { + for remoteCmd in cmds.flatMap({ cmd in getMPRemoteCommands(cmd) }) { + remoteCmd.isEnabled = false + remoteCmd.removeTarget(self) + } + } + + func registerRemoteControlCommands() { + // MPRemoteCommandCenter on iOS can have a max of 3 commands enabled at a time. Any others won't be shown. + for command in remoteCommandsToRegister { + command.removeTarget(self) + command.addTarget(self, action: #selector(handleRemoteControlCommandEvent(_:))) + command.isEnabled = true + } + } + + func setRemoteControlCommandsEnabled(_ enabled: Bool) { + for remoteCmd in remoteCommandsToRegister { + remoteCmd.isEnabled = enabled + } + } + + @objc func handleRemoteControlCommandEvent(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + guard (player?.currentItem) != nil else { + if #available(iOS 9.1, tvOS 9.1, *) { + return .noActionableNowPlayingItem + } else { + return .commandFailed + } + } + let remote = MPRemoteCommandCenter.shared() + + // Assume success, must set to .failed inside case if necessary + var handlerStatus = MPRemoteCommandHandlerStatus.success + + if event.command == remote.stopCommand { + self.stop() + } else if event.command == remote.nextTrackCommand { + self.next() + } else if event.command == remote.previousTrackCommand { + self.previous() + } else if event.command == remote.pauseCommand || + (event.command == remote.togglePlayPauseCommand && state.isPlaying) { + self.pause() + } else if event.command == remote.playCommand || + (event.command == remote.togglePlayPauseCommand && state.isPaused) { + self.resume() + } else if event.command == remote.seekBackwardCommand { + handleRemoteControlSeekEvent(event, isForward: false) + } else if event.command == remote.seekForwardCommand { + handleRemoteControlSeekEvent(event, isForward: true) + } else if event.command == remote.skipBackwardCommand { + if let event = event as? MPSkipIntervalCommandEvent { + self.seekToRelativeTime(-event.interval) + } + } else if event.command == remote.skipForwardCommand { + if let event = event as? MPSkipIntervalCommandEvent { + self.seekToRelativeTime(event.interval) + } + } else if event.command == remote.changePlaybackRateCommand { + if let event = event as? MPChangePlaybackRateCommandEvent { + self.rate = event.playbackRate + } + } else if #available(iOS 9.1, tvOS 9.1, *), event.command == remote.changePlaybackPositionCommand { + handleChangePlaybackPositionEvent(event) + } else if event.command == remote.changeRepeatModeCommand { + handleChangeRepeatModeEvent(event) + } else if event.command == remote.changeShuffleModeCommand { + handleChangeShuffleModeEvent(event) + } else { + handlerStatus = .commandFailed + } + return handlerStatus + } + + private var remoteCommandsToRegister: [MPRemoteCommand] { + get { + return remoteCommandsEnabled.flatMap({ cmd in getMPRemoteCommands(cmd) }) + } + } + + private func handleChangePlaybackPositionEvent(_ event: MPRemoteCommandEvent) { + guard let event = event as? MPChangePlaybackPositionCommandEvent else { + return + } + self.seek(to: event.positionTime) + } + + private func handleRemoteControlSeekEvent(_ event: MPRemoteCommandEvent, isForward: Bool) { + guard let event = event as? MPSeekCommandEvent else { + return + } + if (event.type == .beginSeeking) { + seekingBehavior.handleSeekingStart(player: self, forward: isForward) + } else { + seekingBehavior.handleSeekingEnd(player: self, forward: isForward) + } + } + + private func handleChangeRepeatModeEvent(_ event: MPRemoteCommandEvent) { + guard let event = event as? MPChangeRepeatModeCommandEvent else { + return + } + let newRepeatMode: AudioPlayerMode + switch event.repeatType { + case .one: + newRepeatMode = .repeat + case .all: + newRepeatMode = .repeatAll + case .off: + newRepeatMode = .normal + default: + newRepeatMode = .normal + } + self.mode = self.mode.contains(.shuffle) ? [.shuffle, newRepeatMode] : newRepeatMode + } + + private func handleChangeShuffleModeEvent(_ event: MPRemoteCommandEvent) { + guard let event = event as? MPChangeShuffleModeCommandEvent else { + return + } + if event.shuffleType == .off { + self.mode.remove(.shuffle) + } else { + self.mode.insert(.shuffle) + } + } + + private func getMPRemoteCommands(_ command: AudioPlayerRemoteCommand) -> [MPRemoteCommand] { + let remote = MPRemoteCommandCenter.shared() + switch command { + case .playPause: + return [remote.togglePlayPauseCommand, remote.playCommand, remote.pauseCommand] + case .stop: + return [remote.stopCommand] + case .nextTrack: + return [remote.nextTrackCommand] + case .previousTrack: + return [remote.previousTrackCommand] + case .skipForward: + return [remote.skipForwardCommand] + case .skipBackward: + return [remote.skipBackwardCommand] + case .seekForward: + return [remote.seekForwardCommand] + case .seekBackward: + return [remote.seekBackwardCommand] + case .changePlaybackRate: + return [remote.changePlaybackRateCommand] + case .changePlaybackPosition: + if #available(iOS 9.1, tvOS 9.1, *) { + return [remote.changePlaybackPositionCommand] + } else { + return [] + } + case .changeRepeatMode: + if #available(iOS 10, tvOS 10, *) { + return [remote.changeRepeatModeCommand] + } else { + return [] + } + case .changeShuffleMode: + if #available(iOS 10, tvOS 10, *) { + return [remote.changeShuffleModeCommand] + } else { + return [] + } + case .rate: + return [remote.ratingCommand] + case .like: + return [remote.likeCommand] + case .dislike: + return [remote.dislikeCommand] + case .bookmark: + return [remote.bookmarkCommand] + } + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+RetryEvent.swift b/ios/Classes/player/extensions/AudioPlayer+RetryEvent.swift new file mode 100644 index 0000000..4e2501a --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+RetryEvent.swift @@ -0,0 +1,28 @@ +// +// AudioPlayer+RetryEvent.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 15/04/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +extension AudioPlayer { + /// Handles retry events. + /// + /// - Parameters: + /// - producer: The event producer that generated the retry event. + /// - event: The retry event. + func handleRetryEvent(from producer: EventProducer, with event: RetryEventProducer.RetryEvent) { + switch event { + case .retryAvailable: + retryOrPlayNext() + + case .retryFailed: + state = .failed + failedError = AudioPlayerError.maximumRetryCountHit + producer.stopProducingEvents() + } + } +} diff --git a/ios/Classes/player/extensions/AudioPlayer+SeekEvent.swift b/ios/Classes/player/extensions/AudioPlayer+SeekEvent.swift new file mode 100644 index 0000000..d003ddd --- /dev/null +++ b/ios/Classes/player/extensions/AudioPlayer+SeekEvent.swift @@ -0,0 +1,29 @@ +// +// AudioPlayer+SeekEvent.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 2016-10-27. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +extension AudioPlayer { + /// Handles seek events. + /// + /// - Parameters: + /// - producer: The event producer that generated the seek event. + /// - event: The seek event. + func handleSeekEvent(from producer: EventProducer, with event: SeekEventProducer.SeekEvent) { + guard let currentItemProgression = currentItemProgression, + case .changeTime(_, let delta) = seekingBehavior else { return } + + switch event { + case .seekBackward: + seek(to: currentItemProgression - delta) + + case .seekForward: + seek(to: currentItemProgression + delta) + } + } +} diff --git a/ios/Classes/utils/BackgroundHandler.swift b/ios/Classes/utils/BackgroundHandler.swift new file mode 100644 index 0000000..30c403f --- /dev/null +++ b/ios/Classes/utils/BackgroundHandler.swift @@ -0,0 +1,110 @@ +// +// BackgroundHandler.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 22/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +#if os(OSX) + import Foundation +#else + import UIKit + + /// A `BackgroundTaskCreator` serves the purpose of creating background tasks. + protocol BackgroundTaskCreator: AnyObject { + /// Marks the beginning of a new long-running background task. + /// + /// - Parameter handler: A handler to be called shortly before the app’s remaining background time reaches 0. + /// You should use this handler to clean up and mark the end of the background task. Failure to end the task + /// explicitly will result in the termination of the app. The handler is called synchronously on the main + /// thread, blocking the app’s suspension momentarily while the app is notified. + /// - Returns: A unique identifier for the new background task. You must pass this value to the + /// `endBackgroundTask:` method to mark the end of this task. This method returns `UIBackgroundTaskInvalid` + /// if running in the background is not possible. + func beginBackgroundTask(expirationHandler handler: (() -> Void)?) -> UIBackgroundTaskIdentifier + + /// Marks the end of a specific long-running background task. + /// + /// You must call this method to end a task that was started using the `beginBackgroundTask(expirationHandler:)` + /// method. If you do not, the system may kill your app. + /// + /// This method can be safely called on a non-main thread. + /// + /// - Parameter identifier: An identifier returned by the `beginBackgroundTask(expirationHandler:)` method. + func endBackgroundTask(_ identifier: UIBackgroundTaskIdentifier) + } + + extension UIApplication: BackgroundTaskCreator {} +#endif + +/// A `BackgroundHandler` handles background. +class BackgroundHandler: NSObject { + #if !os(OSX) + /// The background task creator + var backgroundTaskCreator: BackgroundTaskCreator = UIApplication.shared + /// The backround task identifier if a background task started. Nil if not. + private var taskIdentifier: UIBackgroundTaskIdentifier? + #else + private var taskIdentifier: Int? + #endif + + /// The number of background request received. When this counter hits 0, the background task, if any, will be + /// terminated. + private var counter = 0 + + /// Ends background task if any on deinitialization. + deinit { + endBackgroundTask() + } + + /// Starts a background task if there isn't already one. + /// + /// - Returns: A boolean value indicating whether a background task was created or not. + @discardableResult + func beginBackgroundTask() -> Bool { + #if os(OSX) + return false + #else + counter += 1 + + guard taskIdentifier == nil else { + return false + } + + taskIdentifier = backgroundTaskCreator.beginBackgroundTask { [weak self] in + if let taskIdentifier = self?.taskIdentifier { + self?.backgroundTaskCreator.endBackgroundTask(taskIdentifier) + } + self?.taskIdentifier = nil + } + return true + #endif + } + + /// Ends the background task if there is one. + /// + /// - Returns: A boolean value indicating whether a background task was ended or not. + @discardableResult + func endBackgroundTask() -> Bool { + #if os(OSX) + return false + #else + guard let taskIdentifier = taskIdentifier else { + return false + } + + counter -= 1 + + guard counter == 0 else { + return false + } + + if taskIdentifier != UIBackgroundTaskIdentifier.invalid { + backgroundTaskCreator.endBackgroundTask(taskIdentifier) + } + self.taskIdentifier = nil + return true + #endif + } +} diff --git a/ios/Classes/utils/CMTime+TimeIntervalValue.swift b/ios/Classes/utils/CMTime+TimeIntervalValue.swift new file mode 100644 index 0000000..415d670 --- /dev/null +++ b/ios/Classes/utils/CMTime+TimeIntervalValue.swift @@ -0,0 +1,30 @@ +// +// CMTime+TimeIntervalValue.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 11/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import CoreMedia + +extension CMTime { + /// Initializes a `CMTime` instance from a time interval. + /// + /// - Parameter timeInterval: The time in seconds. + init(timeInterval: TimeInterval) { + self.init(seconds: timeInterval, preferredTimescale: 1000000000) + } + + //swiftlint:disable variable_name + /// Returns the TimerInterval value of CMTime (only if it's a valid value). + var ap_timeIntervalValue: TimeInterval? { + if flags.contains(.valid) { + let seconds = CMTimeGetSeconds(self) + if !seconds.isNaN { + return TimeInterval(seconds) + } + } + return nil + } +} diff --git a/ios/Classes/utils/Debug.swift b/ios/Classes/utils/Debug.swift new file mode 100644 index 0000000..47ff965 --- /dev/null +++ b/ios/Classes/utils/Debug.swift @@ -0,0 +1,39 @@ +// +// Logger.swift +// AudioPlayer +// +// Created by Daniel Dam Freiling on 08/06/2017. +// Copyright © 2017 Kevin Delannoy. All rights reserved. +// + +import Foundation + +public func KDEDebug(_ items: Any..., separator: String = " ", terminator: String = "\n") { + #if DEBUG + + var idx = items.startIndex + let endIdx = items.endIndex + + repeat { + Swift.print(items[idx], separator: separator, terminator: idx == (endIdx - 1) ? terminator : separator) + idx += 1 + } + while idx < endIdx + + #endif +} + +public func KDEDebugDump(_ items: Any..., separator: String = " ", terminator: String = "\n") { + #if DEBUG + + var idx = items.startIndex + let endIdx = items.endIndex + + repeat { + Swift.debugPrint(items[idx], separator: separator, terminator: idx == (endIdx - 1) ? terminator : separator) + idx += 1 + } + while idx < endIdx + + #endif +} diff --git a/ios/Classes/utils/MPNowPlayingInfoCenter+AudioItem.swift b/ios/Classes/utils/MPNowPlayingInfoCenter+AudioItem.swift new file mode 100644 index 0000000..2ec1103 --- /dev/null +++ b/ios/Classes/utils/MPNowPlayingInfoCenter+AudioItem.swift @@ -0,0 +1,49 @@ +// +// MPNowPlayingInfoCenter+AudioItem.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 27/03/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import MediaPlayer + +extension MPNowPlayingInfoCenter { + /// Updates the MPNowPlayingInfoCenter with the latest information on a `AudioItem`. + /// + /// - Parameters: + /// - item: The item that is currently played. + /// - duration: The item's duration. + /// - progression: The current progression. + /// - playbackRate: The current playback rate. + func ap_update(with item: AudioItem, duration: TimeInterval?, progression: TimeInterval?, playbackRate: Float) { + var info = [String: Any]() + if let title = item.title { + info[MPMediaItemPropertyTitle] = title + } + if let artist = item.artist { + info[MPMediaItemPropertyArtist] = artist + } + if let album = item.album { + info[MPMediaItemPropertyAlbumTitle] = album + } + if let trackCount = item.trackCount { + info[MPMediaItemPropertyAlbumTrackCount] = trackCount + } + if let trackNumber = item.trackNumber { + info[MPMediaItemPropertyAlbumTrackNumber] = trackNumber + } + if let artwork = item.artwork { + info[MPMediaItemPropertyArtwork] = artwork + } + if let duration = duration { + info[MPMediaItemPropertyPlaybackDuration] = duration + } + if let progression = progression { + info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = progression + } + info[MPNowPlayingInfoPropertyPlaybackRate] = playbackRate + + nowPlayingInfo = info + } +} diff --git a/ios/Classes/utils/URL+Offline.swift b/ios/Classes/utils/URL+Offline.swift new file mode 100644 index 0000000..d1a7c8e --- /dev/null +++ b/ios/Classes/utils/URL+Offline.swift @@ -0,0 +1,18 @@ +// +// URL+Offline.swift +// AudioPlayer +// +// Created by Kevin DELANNOY on 03/04/16. +// Copyright © 2016 Kevin Delannoy. All rights reserved. +// + +import Foundation + +extension URL { + //swiftlint:disable variable_name + /// A boolean value indicating whether a resource should be considered available when internet connection is down + /// or not. + var ap_isOfflineURL: Bool { + return isFileURL || scheme == "ipod-library" || host == "localhost" || host == "127.0.0.1" + } +} diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..a6b6d6c --- /dev/null +++ b/ios/README.md @@ -0,0 +1,9 @@ +# iOS AudioPlayer +Nota fork of Swift AudioPlayer: +https://github.com/delannoyk/AudioPlayer + +# Current version +The native iOS code for AudioPlayer is checked out from https://github.com/ddfreiling/AudioPlayer + +Current commit: +[3fe6186](https://github.com/ddfreiling/AudioPlayer/commit/3fe6186243f2fbfc6df57a3cae03d8ee04da66ab)