Compare commits

..

7 Commits

Author SHA1 Message Date
dcvz 7b506bebab Bump to v1.1.0 2024-03-25 14:30:43 +01:00
Jonathan Puckey ea82b81ed9 Improve handling of playWhenReady parameters (#69)
This fixes an issue where calling one of the player methods with an optional playWhenReady parameter with playWhenReady= true, it would first start loading the current track before the track-changing action was called and then it would be called again because the track changed.

Instead, when playWhenReady is false, playback is paused before changing the track. When playWhenReady is true, playback is started after changing the track – which causes only the new track to start loading.
2024-03-25 14:28:44 +01:00
Kirill Zyusko 03c988e8b1 fix: broken progress bar after repeat (#75) 2024-03-25 14:27:22 +01:00
Fonos-development 2424550401 Fix crash on attaching metadata output (#74)
* fix: delay attach metadata more to avoid duplicate attachment

* fix: ensuring each AVPlayerItem has its own metadataOutput

* refactor: safely check current metadata output before broadcasting and removal

---------

Co-authored-by: Tuan Dinh <tuandtb@fono.vn>
2024-03-08 01:05:21 +01:00
dcvz 5d8b3f2be5 chore(docs): Fix README 2024-03-05 09:54:24 +01:00
David Chavez e1999c935e chore(infra): Update runners (#76) 2024-03-05 09:40:09 +01:00
Jonathan Puckey fd8290c537 fix(metadata): Avoid emitting empty common metadata. (#70) 2023-11-06 09:43:33 +01:00
7 changed files with 143 additions and 64 deletions
+2 -5
View File
@@ -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
+47
View File
@@ -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 {
+49 -24
View File
@@ -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)
}
}
}
+15 -17
View File
@@ -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) {
+1 -1
View File
@@ -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.