Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b506bebab | |||
| ea82b81ed9 | |||
| 03c988e8b1 | |||
| 2424550401 | |||
| 5d8b3f2be5 | |||
| e1999c935e | |||
| fd8290c537 |
@@ -9,13 +9,10 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: macos-latest
|
||||
runs-on: blaze/macos-14
|
||||
strategy:
|
||||
matrix:
|
||||
destination:
|
||||
[
|
||||
'platform=iOS Simulator,name=iPhone 12 Pro',
|
||||
]
|
||||
destination: ["platform=iOS Simulator,name=iPhone 15 Pro"]
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v2
|
||||
|
||||
@@ -7,6 +7,20 @@
|
||||
|
||||
SwiftAudioEx is an audio player written in Swift, making it simpler to work with audio playback from streams and files.
|
||||
|
||||
<div align="left" valign="middle">
|
||||
<a href="https://runblaze.dev">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.runblaze.dev/logo_dark.png">
|
||||
<img align="right" src="https://www.runblaze.dev/logo_light.png" height="102px"/>
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
<br style="display: none;"/>
|
||||
|
||||
_[Blaze](https://runblaze.dev) sponsors SwiftAudioEx by providing super fast Apple Silicon based macOS Github Action Runners. Use the discount code `RNTP50` at checkout to get 50% off your first year._
|
||||
|
||||
</div>
|
||||
|
||||
## Example
|
||||
|
||||
To see the audio player in action, run the example project!
|
||||
@@ -16,11 +30,13 @@ XCode project navigator and Build/Run it in a simulator (or on an actual
|
||||
device).
|
||||
|
||||
## Requirements
|
||||
|
||||
iOS 11.0+
|
||||
|
||||
## Installation
|
||||
|
||||
### Swift Package Manager
|
||||
|
||||
[Swift Package Manager](https://swift.org/package-manager/) (SwiftPM) is a tool for managing the distribution of Swift code as well as C-family dependency. From Xcode 11, SwiftPM got natively integrated with Xcode.
|
||||
|
||||
SwiftAudioEx supports SwiftPM from version 0.12.0. To use SwiftPM, you should use Xcode 11 to open your project. Click `File` -> `Swift Packages` -> `Add Package Dependency`, enter [SwiftAudioEx repo's URL](https://github.com/doublesymmetry/SwiftAudio.git). Or you can login Xcode with your GitHub account and just type `SwiftAudioEx` to search.
|
||||
@@ -40,6 +56,7 @@ let package = Package(
|
||||
```
|
||||
|
||||
### CocoaPods
|
||||
|
||||
SwiftAudioEx is available through [CocoaPods](http://cocoapods.org). To install
|
||||
it, simply add the following line to your Podfile:
|
||||
|
||||
@@ -48,16 +65,21 @@ pod 'SwiftAudioEx', '~> 1.0.0'
|
||||
```
|
||||
|
||||
### Carthage
|
||||
|
||||
SwiftAudioEx supports [Carthage](https://github.com/Carthage/Carthage). Add this to your Cartfile:
|
||||
|
||||
```ruby
|
||||
github "doublesymmetry/SwiftAudioEx" ~> 1.0.0
|
||||
```
|
||||
|
||||
Then follow the rest of Carthage instructions on [adding a framework](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application).
|
||||
|
||||
## Usage
|
||||
|
||||
### AudioPlayer
|
||||
|
||||
To get started playing some audio:
|
||||
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
let audioItem = DefaultAudioItem(audioUrl: "someUrl", sourceType: .stream)
|
||||
@@ -66,6 +88,7 @@ player.load(item: audioItem, playWhenReady: true) // Load the item and start pla
|
||||
|
||||
To listen for events in the `AudioPlayer`, subscribe to events found in the `event` property of the `AudioPlayer`.
|
||||
To subscribe to an event:
|
||||
|
||||
```swift
|
||||
class MyCustomViewController: UIViewController {
|
||||
|
||||
@@ -83,7 +106,9 @@ class MyCustomViewController: UIViewController {
|
||||
```
|
||||
|
||||
#### QueuedAudioPlayer
|
||||
|
||||
The `QueuedAudioPlayer` is a subclass of `AudioPlayer` that maintains a queue of audio tracks.
|
||||
|
||||
```swift
|
||||
let player = QueuedAudioPlayer()
|
||||
let audioItem = DefaultAudioItem(audioUrl: "someUrl", sourceType: .stream)
|
||||
@@ -93,7 +118,9 @@ player.add(item: audioItem, playWhenReady: true) // Since this is the first item
|
||||
When a track is done playing, the player will load the next track and update the queue.
|
||||
|
||||
##### Navigating the queue
|
||||
|
||||
All `AudioItem`s are stored in either `previousItems` or `nextItems`, which refers to items that come prior to the `currentItem` and after, respectively. The queue is navigated with:
|
||||
|
||||
```swift
|
||||
player.next() // Increments the queue, and loads the next item.
|
||||
player.previous() // Decrements the queue, and loads the previous item.
|
||||
@@ -101,13 +128,16 @@ player.jumpToItem(atIndex:) // Jumps to a certain item and loads that item.
|
||||
```
|
||||
|
||||
##### Manipulating the queue
|
||||
|
||||
```swift
|
||||
player.removeItem(at:) // Remove a specific item from the queue.
|
||||
player.removeUpcomingItems() // Remove all items in nextItems.
|
||||
```
|
||||
|
||||
### Configuring the AudioPlayer
|
||||
|
||||
Current options for configuring the `AudioPlayer`:
|
||||
|
||||
- `bufferDuration`: The amount of seconds to be buffered by the player.
|
||||
- `timeEventFrequency`: How often the player should call the delegate with time progress events.
|
||||
- `automaticallyWaitsToMinimizeStalling`: Indicates whether the player should automatically delay playback in order to minimize stalling.
|
||||
@@ -117,10 +147,13 @@ Current options for configuring the `AudioPlayer`:
|
||||
- `audioTimePitchAlgorithm`: This value decides the `AVAudioTimePitchAlgorithm` used for each `AudioItem`. Implement `TimePitching` in your `AudioItem`-subclass to override individually for each `AudioItem`.
|
||||
|
||||
Options particular to `QueuedAudioPlayer`:
|
||||
|
||||
- `repeatMode`: The repeat mode: off, track, queue
|
||||
|
||||
### Audio Session
|
||||
|
||||
Remember to activate an audio session with an appropriate category for your app. This can be done with `AudioSessionController`:
|
||||
|
||||
```swift
|
||||
try? AudioSessionController.shared.set(category: .playback)
|
||||
//...
|
||||
@@ -133,34 +166,43 @@ try? AudioSessionController.shared.activateSession()
|
||||
App Settings -> Capabilities -> Background Modes -> Check 'Audio, AirPlay, and Picture in Picture'.
|
||||
|
||||
#### Interruptions
|
||||
|
||||
If you are using the `AudioSessionController` for setting up the audio session, you can use it to handle interruptions too.
|
||||
Implement `AudioSessionControllerDelegate` and you will be notified by `handleInterruption(type: AVAudioSessionInterruptionType)`.
|
||||
If you are storing progress for playback time on items when the app quits, it can be a good idea to do it on interruptions as well.
|
||||
To disable interruption notifcations set `isObservingForInterruptions` to `false`.
|
||||
|
||||
### Now Playing Info
|
||||
|
||||
The `AudioPlayer` can automatically update `nowPlayingInfo` for you. This requires `automaticallyUpdateNowPlayingInfo` to be true (default), and that the `AudioItem` that is passed in return values for the getters. The `AudioPlayer` will update: artist, title, album, artwork, elapsed time, duration and rate.
|
||||
|
||||
Additional properties for items can be set by accessing the setter of the `nowPlayingInforController`:
|
||||
|
||||
```swift
|
||||
let player = AudioPlayer()
|
||||
player.load(item: someItem)
|
||||
player.nowPlayingInfoController.set(keyValue: NowPlayingInfoProperty.isLiveStream(true))
|
||||
```
|
||||
|
||||
The set(keyValue:) and set(keyValues:) accept both `MediaItemProperty` and `NowPlayingInfoProperty`.
|
||||
|
||||
The info can be forced to reload/update from the `AudioPlayer`.
|
||||
|
||||
```swift
|
||||
audioPlayer.loadNowPlayingMetaValues()
|
||||
audioPlayer.updateNowPlayingPlaybackValues()
|
||||
```
|
||||
|
||||
The current info can be cleared with:
|
||||
|
||||
```swift
|
||||
audioPlayer.nowPlayingInfoController.clear()
|
||||
```
|
||||
|
||||
### Remote Commands
|
||||
|
||||
To enable remote commands for the player you need to populate the RemoteCommands array for the player:
|
||||
|
||||
```swift
|
||||
audioPlayer.remoteCommands = [
|
||||
.play,
|
||||
@@ -169,19 +211,24 @@ audioPlayer.remoteCommands = [
|
||||
.skipBackward(intervals: [30]),
|
||||
]
|
||||
```
|
||||
|
||||
These commands will be activated for each `AudioItem`. If you need some audio items to have different commands, implement `RemoteCommandable` in a custom `AudioItem`-subclass. These commands will override the commands found in `AudioPlayer.remoteCommands` so make sure to supply all commands you need for that particular `AudioItem`.
|
||||
|
||||
#### Custom handlers for remote commands
|
||||
|
||||
To supply custom handlers for your remote commands, just override the handlers contained in the player's `RemoteCommandController`:
|
||||
|
||||
```swift
|
||||
let player = QueuedAudioPlayer()
|
||||
player.remoteCommandController.handlePlayCommand = { (event) in
|
||||
// Handle remote command here.
|
||||
}
|
||||
```
|
||||
|
||||
All available overrides can be found by looking at `RemoteCommandController`.
|
||||
|
||||
### Start playback from a certain point in time
|
||||
|
||||
Make your `AudioItem`-subclass conform to `InitialTiming` to be able to start playback from a certain time.
|
||||
|
||||
## Author
|
||||
|
||||
@@ -245,7 +245,9 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
|
||||
if (pendingAsset != self.asset) { return; }
|
||||
|
||||
let commonData = pendingAsset.commonMetadata
|
||||
self.delegate?.AVWrapper(didReceiveCommonMetadata: commonData)
|
||||
if (!commonData.isEmpty) {
|
||||
self.delegate?.AVWrapper(didReceiveCommonMetadata: commonData)
|
||||
}
|
||||
|
||||
if pendingAsset.availableChapterLocales.count > 0 {
|
||||
for locale in pendingAsset.availableChapterLocales {
|
||||
|
||||
@@ -42,6 +42,33 @@ public class AudioPlayer: AVPlayerWrapperDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Handles the `playWhenReady` setting while executing a given action.
|
||||
|
||||
This method takes an optional `Bool` value and a closure representing an action to execute.
|
||||
If the `Bool` value is not `nil`, `self.playWhenReady` is set accordingly either before or
|
||||
after executing the action.
|
||||
|
||||
- Parameters:
|
||||
- playWhenReady: Optional `Bool` to set `self.playWhenReady`.
|
||||
- If `true`, `self.playWhenReady` will be set after executing the action.
|
||||
- If `false`, `self.playWhenReady` will be set before executing the action.
|
||||
- If `nil`, `self.playWhenReady` will not be changed.
|
||||
- action: A closure representing the action to execute. This closure can throw an error.
|
||||
|
||||
- Throws: This function will propagate any errors thrown by the `action` closure.
|
||||
*/
|
||||
internal func handlePlayWhenReady(_ playWhenReady: Bool?, action: () throws -> Void) rethrows {
|
||||
if playWhenReady == false {
|
||||
self.playWhenReady = false
|
||||
}
|
||||
|
||||
try action()
|
||||
|
||||
if playWhenReady == true {
|
||||
self.playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Getters from AVPlayerWrapper
|
||||
|
||||
@@ -170,32 +197,30 @@ public class AudioPlayer: AVPlayerWrapperDelegate {
|
||||
- parameter playWhenReady: Optional, whether to start playback when the item is ready.
|
||||
*/
|
||||
public func load(item: AudioItem, playWhenReady: Bool? = nil) {
|
||||
currentItem = item
|
||||
handlePlayWhenReady(playWhenReady) {
|
||||
currentItem = item
|
||||
|
||||
if let playWhenReady = playWhenReady {
|
||||
self.playWhenReady = playWhenReady
|
||||
if (automaticallyUpdateNowPlayingInfo) {
|
||||
// Reset playback values without updating, because that will happen in
|
||||
// the loadNowPlayingMetaValues call straight after:
|
||||
nowPlayingInfoController.setWithoutUpdate(keyValues: [
|
||||
MediaItemProperty.duration(nil),
|
||||
NowPlayingInfoProperty.playbackRate(nil),
|
||||
NowPlayingInfoProperty.elapsedPlaybackTime(nil)
|
||||
])
|
||||
loadNowPlayingMetaValues()
|
||||
}
|
||||
|
||||
enableRemoteCommands(forItem: item)
|
||||
|
||||
wrapper.load(
|
||||
from: item.getSourceUrl(),
|
||||
type: item.getSourceType(),
|
||||
playWhenReady: self.playWhenReady,
|
||||
initialTime: (item as? InitialTiming)?.getInitialTime(),
|
||||
options:(item as? AssetOptionsProviding)?.getAssetOptions()
|
||||
)
|
||||
}
|
||||
|
||||
if (automaticallyUpdateNowPlayingInfo) {
|
||||
// Reset playback values without updating, because that will happen in
|
||||
// the loadNowPlayingMetaValues call straight after:
|
||||
nowPlayingInfoController.setWithoutUpdate(keyValues: [
|
||||
MediaItemProperty.duration(nil),
|
||||
NowPlayingInfoProperty.playbackRate(nil),
|
||||
NowPlayingInfoProperty.elapsedPlaybackTime(nil)
|
||||
])
|
||||
loadNowPlayingMetaValues()
|
||||
}
|
||||
|
||||
enableRemoteCommands(forItem: item)
|
||||
|
||||
wrapper.load(
|
||||
from: item.getSourceUrl(),
|
||||
type: item.getSourceType(),
|
||||
playWhenReady: self.playWhenReady,
|
||||
initialTime: (item as? InitialTiming)?.getInitialTime(),
|
||||
options:(item as? AssetOptionsProviding)?.getAssetOptions()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ protocol AVPlayerItemObserverDelegate: AnyObject {
|
||||
Called when the duration of the observed item is updated.
|
||||
*/
|
||||
func item(didUpdateDuration duration: Double)
|
||||
|
||||
|
||||
/**
|
||||
Called when the playback of the observed item is or is no longer likely to keep up.
|
||||
*/
|
||||
@@ -32,7 +32,7 @@ protocol AVPlayerItemObserverDelegate: AnyObject {
|
||||
class AVPlayerItemObserver: NSObject {
|
||||
|
||||
private static var context = 0
|
||||
private let metadataOutput = AVPlayerItemMetadataOutput()
|
||||
private var currentMetadataOutput: AVPlayerItemMetadataOutput?
|
||||
|
||||
private struct AVPlayerItemKeyPath {
|
||||
static let duration = #keyPath(AVPlayerItem.duration)
|
||||
@@ -47,7 +47,6 @@ class AVPlayerItemObserver: NSObject {
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
metadataOutput.setDelegate(self, queue: .main)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -68,15 +67,13 @@ class AVPlayerItemObserver: NSObject {
|
||||
item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.loadedTimeRanges, options: [.new], context: &AVPlayerItemObserver.context)
|
||||
item.addObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackLikelyToKeepUp, options: [.new], context: &AVPlayerItemObserver.context)
|
||||
|
||||
// We must slightly delay adding the metadata output due to the fact that
|
||||
// stop observation is not a synchronous action and metadataOutput may not
|
||||
// be removed from last item before we try to attach it to a new one.
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) { [weak self] in
|
||||
guard let `self` = self else { return }
|
||||
item.add(self.metadataOutput)
|
||||
}
|
||||
// Create and add a new metadata output to the item.
|
||||
let metadataOutput = AVPlayerItemMetadataOutput()
|
||||
metadataOutput.setDelegate(self, queue: .main)
|
||||
item.add(metadataOutput)
|
||||
self.currentMetadataOutput = metadataOutput
|
||||
}
|
||||
|
||||
|
||||
func stopObservingCurrentItem() {
|
||||
guard let observingItem = observingItem, isObserving else {
|
||||
return
|
||||
@@ -85,10 +82,13 @@ class AVPlayerItemObserver: NSObject {
|
||||
observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.duration, context: &AVPlayerItemObserver.context)
|
||||
observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.loadedTimeRanges, context: &AVPlayerItemObserver.context)
|
||||
observingItem.removeObserver(self, forKeyPath: AVPlayerItemKeyPath.playbackLikelyToKeepUp, context: &AVPlayerItemObserver.context)
|
||||
observingItem.remove(metadataOutput)
|
||||
|
||||
// Remove all metadata outputs from the item.
|
||||
observingItem.removeAllMetadataOutputs()
|
||||
|
||||
isObserving = false
|
||||
self.observingItem = nil
|
||||
self.currentMetadataOutput = nil
|
||||
}
|
||||
|
||||
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
||||
@@ -102,17 +102,17 @@ class AVPlayerItemObserver: NSObject {
|
||||
if let duration = change?[.newKey] as? CMTime {
|
||||
delegate?.item(didUpdateDuration: duration.seconds)
|
||||
}
|
||||
|
||||
|
||||
case AVPlayerItemKeyPath.loadedTimeRanges:
|
||||
if let ranges = change?[.newKey] as? [NSValue], let duration = ranges.first?.timeRangeValue.duration {
|
||||
delegate?.item(didUpdateDuration: duration.seconds)
|
||||
}
|
||||
|
||||
|
||||
case AVPlayerItemKeyPath.playbackLikelyToKeepUp:
|
||||
if let playbackLikelyToKeepUp = change?[.newKey] as? Bool {
|
||||
delegate?.item(didUpdatePlaybackLikelyToKeepUp: playbackLikelyToKeepUp)
|
||||
}
|
||||
|
||||
|
||||
default: break
|
||||
|
||||
}
|
||||
@@ -121,6 +121,16 @@ class AVPlayerItemObserver: NSObject {
|
||||
|
||||
extension AVPlayerItemObserver: AVPlayerItemMetadataOutputPushDelegate {
|
||||
func metadataOutput(_ output: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from track: AVPlayerItemTrack?) {
|
||||
delegate?.item(didReceiveTimedMetadata: groups)
|
||||
if output == currentMetadataOutput {
|
||||
delegate?.item(didReceiveTimedMetadata: groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AVPlayerItem {
|
||||
func removeAllMetadataOutputs() {
|
||||
for output in self.outputs.filter({ $0 is AVPlayerItemMetadataOutput }) {
|
||||
self.remove(output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,9 @@ public class QueuedAudioPlayer: AudioPlayer, QueueManagerDelegate {
|
||||
- parameter playWhenReady: Optional, whether to start playback when the item is ready.
|
||||
*/
|
||||
public override func load(item: AudioItem, playWhenReady: Bool? = nil) {
|
||||
if let playWhenReady = playWhenReady {
|
||||
self.playWhenReady = playWhenReady
|
||||
handlePlayWhenReady(playWhenReady) {
|
||||
queue.replaceCurrentItem(with: item)
|
||||
}
|
||||
queue.replaceCurrentItem(with: item)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,10 +80,9 @@ public class QueuedAudioPlayer: AudioPlayer, QueueManagerDelegate {
|
||||
- parameter playWhenReady: Optional, whether to start playback when the item is ready.
|
||||
*/
|
||||
public func add(item: AudioItem, playWhenReady: Bool? = nil) {
|
||||
if let playWhenReady = playWhenReady {
|
||||
self.playWhenReady = playWhenReady
|
||||
handlePlayWhenReady(playWhenReady) {
|
||||
queue.add(item)
|
||||
}
|
||||
queue.add(item)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -94,10 +92,9 @@ public class QueuedAudioPlayer: AudioPlayer, QueueManagerDelegate {
|
||||
- parameter playWhenReady: Optional, whether to start playback when the item is ready.
|
||||
*/
|
||||
public func add(items: [AudioItem], playWhenReady: Bool? = nil) {
|
||||
if let playWhenReady = playWhenReady {
|
||||
self.playWhenReady = playWhenReady
|
||||
handlePlayWhenReady(playWhenReady) {
|
||||
queue.add(items)
|
||||
}
|
||||
queue.add(items)
|
||||
}
|
||||
|
||||
public func add(items: [AudioItem], at index: Int) throws {
|
||||
@@ -147,15 +144,14 @@ public class QueuedAudioPlayer: AudioPlayer, QueueManagerDelegate {
|
||||
- throws: `AudioPlayerError`
|
||||
*/
|
||||
public func jumpToItem(atIndex index: Int, playWhenReady: Bool? = nil) throws {
|
||||
if let playWhenReady = playWhenReady {
|
||||
self.playWhenReady = playWhenReady
|
||||
try handlePlayWhenReady(playWhenReady) {
|
||||
if (index == currentIndex) {
|
||||
seek(to: 0)
|
||||
} else {
|
||||
_ = try queue.jump(to: index)
|
||||
}
|
||||
event.playbackEnd.emit(data: .jumpedToIndex)
|
||||
}
|
||||
if (index == currentIndex) {
|
||||
seek(to: 0)
|
||||
} else {
|
||||
_ = try queue.jump(to: index)
|
||||
}
|
||||
event.playbackEnd.emit(data: .jumpedToIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +189,8 @@ public class QueuedAudioPlayer: AudioPlayer, QueueManagerDelegate {
|
||||
override func AVWrapperItemDidPlayToEndTime() {
|
||||
event.playbackEnd.emit(data: .playedUntilEnd)
|
||||
if (repeatMode == .track) {
|
||||
self.pause()
|
||||
|
||||
// quick workaround for race condition - schedule a call after 2 frames
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.016 * 2) { [weak self] in self?.replay() }
|
||||
} else if (repeatMode == .queue) {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'SwiftAudioEx'
|
||||
s.version = '1.0.0'
|
||||
s.version = '1.1.0'
|
||||
s.summary = 'Easy audio streaming for iOS'
|
||||
s.description = <<-DESC
|
||||
SwiftAudioEx is an audio player written in Swift, making it simpler to work with audio playback from streams and files.
|
||||
|
||||
Reference in New Issue
Block a user