react-native-audio-browser 0.2.0-next.27 → 0.2.0-next.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/audiobrowser/ServiceBinding.kt +8 -4
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- package/android/src/test/java/com/audiobrowser/ServiceBindingTest.kt +13 -0
- package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
- package/ios/AVPlayerEngine.swift +334 -0
- package/ios/AVPlayerEngineCallbacks.swift +15 -0
- package/ios/Browser/BrowseGate.swift +58 -0
- package/ios/Browser/BrowserConfig.swift +31 -89
- package/ios/Browser/BrowserManager.swift +68 -487
- package/ios/Browser/NavigationErrorState.swift +41 -0
- package/ios/Browser/PageResolver.swift +227 -0
- package/ios/Browser/RequestLayer.swift +90 -0
- package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
- package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
- package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
- package/ios/CarPlay/CarPlayController.swift +42 -1442
- package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
- package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
- package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
- package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
- package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
- package/ios/CarPlay/CarPlaySession.swift +209 -0
- package/ios/CarPlay/CarPlayTemplates.swift +63 -0
- package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
- package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
- package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
- package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
- package/ios/Extension/NavigationError+Conversion.swift +7 -4
- package/ios/Extension/UIImage+Adaptive.swift +12 -0
- package/ios/Extension/URLError+Connectivity.swift +15 -0
- package/ios/Http/HttpClient.swift +63 -145
- package/ios/HybridAudioBrowser+Browser.swift +153 -0
- package/ios/HybridAudioBrowser+Options.swift +144 -0
- package/ios/HybridAudioBrowser+Player.swift +397 -0
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
- package/ios/HybridAudioBrowser+Stubs.swift +46 -0
- package/ios/HybridAudioBrowser.swift +74 -1687
- package/ios/Model/NitroTypeStubs.swift +18 -1
- package/ios/Model/PlayerUpdateOptions.swift +1 -1
- package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
- package/ios/Observer/ItemObserver.swift +90 -0
- package/ios/Observer/PlayerObserver.swift +59 -0
- package/ios/Option/SessionCategories.swift +41 -92
- package/ios/Player/AudioSessionController.swift +177 -0
- package/ios/Player/DeferredSeek.swift +70 -0
- package/ios/Player/ErrorStatus.swift +27 -0
- package/ios/Player/MediaLoader.swift +24 -11
- package/ios/Player/NowPlayingUpdater.swift +157 -58
- package/ios/Player/PlaybackFailure.swift +125 -0
- package/ios/Player/PlaybackPersistence.swift +73 -0
- package/ios/Player/Player.swift +1083 -0
- package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
- package/ios/Player/PlayerEngine.swift +89 -0
- package/ios/Player/PlayerStatusTypes.swift +2 -6
- package/ios/Player/QueueManager.swift +52 -59
- package/ios/Player/RetryLoop.swift +261 -0
- package/ios/Player/SleepTimerManager.swift +1 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
- package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
- package/ios/Tests/DeferredSeekTests.swift +70 -0
- package/ios/Tests/EmitterTests.swift +29 -0
- package/ios/Tests/GenerationMemoTests.swift +121 -0
- package/ios/Tests/LRUCacheTests.swift +4 -5
- package/ios/Tests/MediaLoaderTests.swift +12 -33
- package/ios/Tests/ObserverTests.swift +88 -0
- package/ios/Tests/PlaybackErrorTests.swift +76 -0
- package/ios/Tests/PlaybackFailureTests.swift +189 -0
- package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
- package/ios/Tests/PlayerPolicyTests.swift +405 -0
- package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
- package/ios/Tests/PlayingStateTests.swift +42 -0
- package/ios/Tests/QueueManagerTests.swift +76 -107
- package/ios/Tests/ReadyValueTests.swift +73 -0
- package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
- package/ios/Tests/RemoteCommandTests.swift +2 -4
- package/ios/Tests/RequestLayerTests.swift +112 -0
- package/ios/Tests/RetryLoopTests.swift +270 -0
- package/ios/Tests/Support/TestTypes.swift +111 -112
- package/ios/Tests/TrackIdTemplateTests.swift +1 -1
- package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
- package/ios/TrackSelector.swift +1 -1
- package/ios/Util/ArtworkImageFetcher.swift +42 -0
- package/ios/Util/Emitter.swift +42 -0
- package/ios/Util/GenerationMemo.swift +36 -0
- package/ios/Util/LRUCache.swift +23 -162
- package/ios/Util/NetworkMonitor.swift +1 -1
- package/ios/Util/ReadyValue.swift +56 -0
- package/ios/Util/SFSymbolRenderer.swift +35 -29
- package/ios/Util/SVGProcessor.swift +0 -10
- package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
- package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/module/web/NativeAudioBrowser.js +1 -1
- package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
- package/lib/typescript/src/types/browser.d.ts +3 -3
- package/package.json +1 -1
- package/src/types/browser.ts +3 -3
- package/src/web/NativeAudioBrowser.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
- package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
- package/ios/Bridge.h +0 -8
- package/ios/Browser/MediaResolveComposer.swift +0 -114
- package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
- package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
- package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
- package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
- package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
- package/ios/Observer/PlayerStateObserver.swift +0 -50
- package/ios/Observer/PlayerTimeObserver.swift +0 -55
- package/ios/Option/PitchAlgorithms.swift +0 -17
- package/ios/Option/TimeEventFrequency.swift +0 -18
- package/ios/Player/LoadSeekCoordinator.swift +0 -99
- package/ios/Player/MediaLoaderDelegate.swift +0 -11
- package/ios/Player/PlaybackCoordinator.swift +0 -761
- package/ios/Player/PlaybackEffectHandler.swift +0 -54
- package/ios/Player/PlaybackErrorHandler.swift +0 -175
- package/ios/Player/PlaybackStateStore.swift +0 -27
- package/ios/Player/PlayingStateManager.swift +0 -33
- package/ios/Player/RetryManager.swift +0 -384
- package/ios/Player/SeekCompletionHandler.swift +0 -5
- package/ios/Player/SleepTimerHandling.swift +0 -28
- package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
- package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
- package/ios/Tests/MediaResolveTests.swift +0 -126
- package/ios/Tests/OnceValueTests.swift +0 -79
- package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
- package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
- package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
- package/ios/Tests/PlayingStateManagerTests.swift +0 -57
- package/ios/Tests/RetryManagerTests.swift +0 -178
- package/ios/TrackPlayer.swift +0 -1042
- package/ios/TrackPlayerCallbacks.swift +0 -53
- package/ios/Util/MetadataAdapter.swift +0 -131
- package/ios/Util/OnceValue.swift +0 -104
- /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
- /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
|
@@ -55,6 +55,23 @@
|
|
|
55
55
|
case off
|
|
56
56
|
case track
|
|
57
57
|
case queue
|
|
58
|
+
|
|
59
|
+
init?(fromString string: String) {
|
|
60
|
+
switch string {
|
|
61
|
+
case "off": self = .off
|
|
62
|
+
case "track": self = .track
|
|
63
|
+
case "queue": self = .queue
|
|
64
|
+
default: return nil
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
var stringValue: String {
|
|
69
|
+
switch self {
|
|
70
|
+
case .off: "off"
|
|
71
|
+
case .track: "track"
|
|
72
|
+
case .queue: "queue"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
enum CarPlaySiriListButtonPosition {
|
|
@@ -313,7 +330,7 @@
|
|
|
313
330
|
}
|
|
314
331
|
}
|
|
315
332
|
|
|
316
|
-
// MARK: - Playback Event Types (used by
|
|
333
|
+
// MARK: - Playback Event Types (used by Player)
|
|
317
334
|
|
|
318
335
|
/// Public to match the generated Nitro enum, which `TrackPlayerError`
|
|
319
336
|
/// exposes from a public property.
|
|
@@ -2,7 +2,7 @@ import Foundation
|
|
|
2
2
|
import NitroModules
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Update options for the
|
|
5
|
+
* Update options for the player that can be changed at runtime.
|
|
6
6
|
* These options control player behavior and capabilities that can be modified during playback.
|
|
7
7
|
*/
|
|
8
8
|
class PlayerUpdateOptions {
|
|
@@ -3,62 +3,45 @@ import Foundation
|
|
|
3
3
|
@preconcurrency import MediaPlayer
|
|
4
4
|
import os.log
|
|
5
5
|
|
|
6
|
-
// MPRemoteCommandCenter is not Sendable, but we only use it on the main thread
|
|
7
|
-
extension MPRemoteCommandCenter: @retroactive @unchecked Sendable {}
|
|
8
|
-
|
|
9
6
|
/// Controller for managing Now Playing info.
|
|
10
7
|
///
|
|
11
8
|
/// A `MPNowPlayingSession` bound to the linked AVPlayer publishes now-playing info
|
|
12
9
|
/// automatically: the system derives elapsed time, playback rate, and duration from
|
|
13
10
|
/// the player, so the per-second clock stays out of our code (it extrapolates the
|
|
14
|
-
/// scrubber). We supply
|
|
15
|
-
///
|
|
16
|
-
///
|
|
17
|
-
///
|
|
11
|
+
/// scrubber). We supply what automatic publishing does not: metadata (title/artist/
|
|
12
|
+
/// artwork), attached to `AVPlayerItem.nowPlayingInfo` (the supported channel under
|
|
13
|
+
/// automatic publishing). An explicit `playbackState` would be dropped: iOS ignores it
|
|
14
|
+
/// without a private entitlement (ADR 0005).
|
|
18
15
|
@MainActor
|
|
19
16
|
class NowPlayingInfoController {
|
|
20
17
|
private let logger = Logger(subsystem: "com.audiobrowser", category: "NowPlayingInfoController")
|
|
21
18
|
|
|
22
19
|
private var _info: [String: Any] = [:]
|
|
23
20
|
|
|
24
|
-
/// Last play/pause state pushed to the session's center, to skip redundant writes.
|
|
25
|
-
private var _playbackState: MPNowPlayingPlaybackState?
|
|
26
|
-
|
|
27
21
|
/// The session bound to the linked AVPlayer; held so it stays alive while publishing.
|
|
28
22
|
private var nowPlayingSession: MPNowPlayingSession?
|
|
29
23
|
|
|
30
24
|
/// The linked AVPlayer — metadata is attached to its current item.
|
|
31
25
|
private weak var linkedPlayer: AVPlayer?
|
|
32
26
|
|
|
33
|
-
/// The current remote command center - the session's while linked, else the shared one.
|
|
34
|
-
private(set) var remoteCommandCenter: MPRemoteCommandCenter = .shared()
|
|
35
|
-
|
|
36
27
|
required init() {}
|
|
37
28
|
|
|
38
|
-
///
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
/// Binds an AVPlayer to a new auto-publishing `MPNowPlayingSession`.
|
|
42
|
-
///
|
|
43
|
-
/// - Important: This creates an `MPNowPlayingSession` with its own `remoteCommandCenter`;
|
|
44
|
-
/// the `onRemoteCommandCenterChanged` callback is invoked with the new command center.
|
|
45
|
-
func linkPlayer(_ player: AVPlayer) {
|
|
29
|
+
/// Binds an AVPlayer to a new auto-publishing `MPNowPlayingSession`, and returns the session's
|
|
30
|
+
/// own remote command center, which remote commands must now be enabled on.
|
|
31
|
+
func linkPlayer(_ player: AVPlayer) -> MPRemoteCommandCenter {
|
|
46
32
|
logger.notice("Linking AVPlayer to MPNowPlayingSession for automatic publishing")
|
|
47
33
|
|
|
48
34
|
linkedPlayer = player
|
|
49
35
|
|
|
50
36
|
let session = MPNowPlayingSession(players: [player])
|
|
51
37
|
nowPlayingSession = session
|
|
52
|
-
_playbackState = nil // new center starts at .unknown; force the next write
|
|
53
|
-
remoteCommandCenter = session.remoteCommandCenter
|
|
54
38
|
|
|
55
39
|
session.automaticallyPublishesNowPlayingInfo = true
|
|
56
40
|
|
|
57
41
|
session.becomeActiveIfPossible { success in
|
|
58
42
|
self.logger.notice("MPNowPlayingSession becomeActiveIfPossible: \(success)")
|
|
59
43
|
}
|
|
60
|
-
|
|
61
|
-
onRemoteCommandCenterChanged?(remoteCommandCenter)
|
|
44
|
+
return session.remoteCommandCenter
|
|
62
45
|
}
|
|
63
46
|
|
|
64
47
|
/// Re-requests now-playing activation for the linked session.
|
|
@@ -77,17 +60,14 @@ class NowPlayingInfoController {
|
|
|
77
60
|
}
|
|
78
61
|
}
|
|
79
62
|
|
|
80
|
-
/// Unlinks the AVPlayer
|
|
63
|
+
/// Unlinks the AVPlayer and tears down the session with its command center. Remote commands
|
|
64
|
+
/// never fall back to the shared center, whose targets belong to the host app.
|
|
81
65
|
func unlinkPlayer() {
|
|
82
66
|
logger.info("Unlinking AVPlayer from MPNowPlayingSession")
|
|
83
67
|
|
|
84
68
|
linkedPlayer = nil
|
|
85
69
|
nowPlayingSession?.automaticallyPublishesNowPlayingInfo = false
|
|
86
70
|
nowPlayingSession = nil
|
|
87
|
-
_playbackState = nil
|
|
88
|
-
|
|
89
|
-
remoteCommandCenter = MPRemoteCommandCenter.shared()
|
|
90
|
-
onRemoteCommandCenterChanged?(remoteCommandCenter)
|
|
91
71
|
}
|
|
92
72
|
|
|
93
73
|
/// Keys the session derives from the player itself — we never set these manually,
|
|
@@ -112,22 +92,18 @@ class NowPlayingInfoController {
|
|
|
112
92
|
}
|
|
113
93
|
}
|
|
114
94
|
|
|
115
|
-
/// Sets
|
|
116
|
-
///
|
|
117
|
-
|
|
95
|
+
/// Sets now-playing fields, keyed by their `MPMediaItemProperty*` / `MPNowPlayingInfoProperty*`
|
|
96
|
+
/// names (nil removes one), and publishes when any changed. Elapsed time, duration and rate
|
|
97
|
+
/// are the session's to publish and are ignored. Returns whether anything changed.
|
|
98
|
+
@discardableResult
|
|
99
|
+
func set(_ fields: [String: Any?]) -> Bool {
|
|
118
100
|
var changed = false
|
|
119
|
-
for
|
|
120
|
-
_info[
|
|
101
|
+
for (key, value) in fields where !Self.autoPublishedKeys.contains(key) && !valueUnchanged(key, value) {
|
|
102
|
+
_info[key] = value
|
|
121
103
|
changed = true
|
|
122
104
|
}
|
|
123
105
|
if changed { performUpdate() }
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
/// Sets a single key-value and immediately updates Now Playing.
|
|
127
|
-
func set(keyValue: NowPlayingInfoKeyValue) {
|
|
128
|
-
guard !Self.autoPublishedKeys.contains(keyValue.key), !valueUnchanged(keyValue.key, keyValue.value) else { return }
|
|
129
|
-
_info[keyValue.key] = keyValue.value
|
|
130
|
-
performUpdate()
|
|
106
|
+
return changed
|
|
131
107
|
}
|
|
132
108
|
|
|
133
109
|
/// Attaches metadata to the current item — the supported channel under automatic publishing.
|
|
@@ -155,15 +131,4 @@ class NowPlayingInfoController {
|
|
|
155
131
|
linkedPlayer?.currentItem?.nowPlayingInfo = nil
|
|
156
132
|
nowPlayingSession?.nowPlayingInfoCenter.nowPlayingInfo = nil
|
|
157
133
|
}
|
|
158
|
-
|
|
159
|
-
/// Sets the play/pause state CarPlay / Control Center show for their transport
|
|
160
|
-
/// button. Automatic publishing fills the info dict (metadata/elapsed/rate) but
|
|
161
|
-
/// not the explicit `playbackState`, so the coordinator pushes the user's
|
|
162
|
-
/// play/pause intent here on intent/state changes.
|
|
163
|
-
func setPlaybackState(playing: Bool) {
|
|
164
|
-
let state: MPNowPlayingPlaybackState = playing ? .playing : .paused
|
|
165
|
-
guard state != _playbackState else { return }
|
|
166
|
-
_playbackState = state
|
|
167
|
-
nowPlayingSession?.nowPlayingInfoCenter.playbackState = state
|
|
168
|
-
}
|
|
169
134
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
@preconcurrency import AVFoundation
|
|
2
|
+
|
|
3
|
+
// Delivered on the main queue through AVPlayerItemMetadataOutputPushDelegate.
|
|
4
|
+
extension AVTimedMetadataGroup: @retroactive @unchecked Sendable {}
|
|
5
|
+
|
|
6
|
+
/// Observes one AVPlayerItem from creation to `invalidate()`: its status and buffer, the
|
|
7
|
+
/// end-of-item and stall notifications, and its timed metadata. KVO and notifications arrive off
|
|
8
|
+
/// the main thread; anything that lands after `invalidate()` is dropped, so an old item's late
|
|
9
|
+
/// failure never reaches the next item's handlers.
|
|
10
|
+
@MainActor
|
|
11
|
+
final class ItemObserver: NSObject {
|
|
12
|
+
enum Event {
|
|
13
|
+
case likelyToKeepUp(Bool)
|
|
14
|
+
case failed(Error?)
|
|
15
|
+
case playedToEnd
|
|
16
|
+
case failedToPlayToEnd(Error?)
|
|
17
|
+
/// The buffer emptied mid-playback.
|
|
18
|
+
case stalled
|
|
19
|
+
case timedMetadata([AVTimedMetadataGroup])
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private let item: AVPlayerItem
|
|
23
|
+
private let onEvent: @MainActor (Event) -> Void
|
|
24
|
+
private var observations: [NSKeyValueObservation] = []
|
|
25
|
+
private var notificationTokens: [any NSObjectProtocol] = []
|
|
26
|
+
private let metadataOutput = AVPlayerItemMetadataOutput()
|
|
27
|
+
private(set) var isActive = true
|
|
28
|
+
|
|
29
|
+
init(item: AVPlayerItem, onEvent: @escaping @MainActor (Event) -> Void) {
|
|
30
|
+
self.item = item
|
|
31
|
+
self.onEvent = onEvent
|
|
32
|
+
super.init()
|
|
33
|
+
observations = [
|
|
34
|
+
item.observe(\.isPlaybackLikelyToKeepUp, options: [.new]) { [weak self] item, _ in
|
|
35
|
+
let isLikely = item.isPlaybackLikelyToKeepUp
|
|
36
|
+
Task { @MainActor in self?.emit(.likelyToKeepUp(isLikely)) }
|
|
37
|
+
},
|
|
38
|
+
item.observe(\.status, options: [.new]) { [weak self] item, _ in
|
|
39
|
+
guard item.status == .failed else { return }
|
|
40
|
+
let error = item.error
|
|
41
|
+
Task { @MainActor in self?.emit(.failed(error)) }
|
|
42
|
+
},
|
|
43
|
+
]
|
|
44
|
+
notificationTokens = [
|
|
45
|
+
observe(.AVPlayerItemDidPlayToEndTime) { _ in .playedToEnd },
|
|
46
|
+
observe(.AVPlayerItemFailedToPlayToEndTime) {
|
|
47
|
+
.failedToPlayToEnd($0.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error)
|
|
48
|
+
},
|
|
49
|
+
observe(.AVPlayerItemPlaybackStalled) { _ in .stalled },
|
|
50
|
+
]
|
|
51
|
+
metadataOutput.setDelegate(self, queue: .main)
|
|
52
|
+
item.add(metadataOutput)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func invalidate() {
|
|
56
|
+
guard isActive else { return }
|
|
57
|
+
isActive = false
|
|
58
|
+
observations.removeAll()
|
|
59
|
+
for token in notificationTokens {
|
|
60
|
+
NotificationCenter.default.removeObserver(token)
|
|
61
|
+
}
|
|
62
|
+
notificationTokens = []
|
|
63
|
+
item.remove(metadataOutput)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private func observe(
|
|
67
|
+
_ name: Notification.Name,
|
|
68
|
+
_ event: @escaping @Sendable (Notification) -> Event,
|
|
69
|
+
) -> any NSObjectProtocol {
|
|
70
|
+
NotificationCenter.default.addObserver(forName: name, object: item, queue: nil) { [weak self] notification in
|
|
71
|
+
let event = event(notification)
|
|
72
|
+
Task { @MainActor in self?.emit(event) }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private func emit(_ event: Event) {
|
|
77
|
+
guard isActive else { return }
|
|
78
|
+
onEvent(event)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
extension ItemObserver: AVPlayerItemMetadataOutputPushDelegate {
|
|
83
|
+
nonisolated func metadataOutput(
|
|
84
|
+
_: AVPlayerItemMetadataOutput,
|
|
85
|
+
didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup],
|
|
86
|
+
from _: AVPlayerItemTrack?,
|
|
87
|
+
) {
|
|
88
|
+
MainActor.assumeIsolated { emit(.timedMetadata(groups)) }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
|
|
3
|
+
/// Observes one AVPlayer from creation to `invalidate()`: its status, its time-control status,
|
|
4
|
+
/// and the moment audio starts. A recreated player gets a new observer, so nothing a released
|
|
5
|
+
/// player reports reaches its successor.
|
|
6
|
+
@MainActor
|
|
7
|
+
final class PlayerObserver {
|
|
8
|
+
enum Event {
|
|
9
|
+
case status(AVPlayer.Status)
|
|
10
|
+
case timeControlStatus(AVPlayer.TimeControlStatus)
|
|
11
|
+
/// Playback crossed the first millisecond.
|
|
12
|
+
case audioDidStart
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// A boundary time cannot be zero.
|
|
16
|
+
private static let startBoundaryTime = CMTime(value: 1, timescale: 1000)
|
|
17
|
+
|
|
18
|
+
private let player: AVPlayer
|
|
19
|
+
private let onEvent: @MainActor (Event) -> Void
|
|
20
|
+
private var observations: [NSKeyValueObservation] = []
|
|
21
|
+
private(set) var boundaryObserverToken: Any?
|
|
22
|
+
private var isActive = true
|
|
23
|
+
|
|
24
|
+
init(player: AVPlayer, onEvent: @escaping @MainActor (Event) -> Void) {
|
|
25
|
+
self.player = player
|
|
26
|
+
self.onEvent = onEvent
|
|
27
|
+
observations = [
|
|
28
|
+
player.observe(\.status, options: [.new, .initial]) { [weak self] player, _ in
|
|
29
|
+
let status = player.status
|
|
30
|
+
Task { @MainActor in self?.emit(.status(status)) }
|
|
31
|
+
},
|
|
32
|
+
player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
|
|
33
|
+
let status = player.timeControlStatus
|
|
34
|
+
Task { @MainActor in self?.emit(.timeControlStatus(status)) }
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
boundaryObserverToken = player.addBoundaryTimeObserver(
|
|
38
|
+
forTimes: [NSValue(time: Self.startBoundaryTime)], queue: nil,
|
|
39
|
+
) { [weak self] in
|
|
40
|
+
Task { @MainActor in self?.emit(.audioDidStart) }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Stops all delivery, including events already hopping to the main actor. An AVPlayer
|
|
45
|
+
/// retains its time observers, so one left registered outlives the observer (#96).
|
|
46
|
+
func invalidate() {
|
|
47
|
+
isActive = false
|
|
48
|
+
observations.removeAll()
|
|
49
|
+
if let boundaryObserverToken {
|
|
50
|
+
player.removeTimeObserver(boundaryObserverToken)
|
|
51
|
+
self.boundaryObserverToken = nil
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private func emit(_ event: Event) {
|
|
56
|
+
guard isActive else { return }
|
|
57
|
+
onEvent(event)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -1,123 +1,72 @@
|
|
|
1
1
|
import AVFoundation
|
|
2
|
-
import Foundation
|
|
3
|
-
import MediaPlayer
|
|
4
2
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
func mapConfigToAVAudioSessionCategory() -> AVAudioSession.Category {
|
|
3
|
+
extension IOSCategory {
|
|
4
|
+
var avCategory: AVAudioSession.Category {
|
|
9
5
|
switch self {
|
|
10
|
-
case .
|
|
11
|
-
|
|
12
|
-
case .multiRoute
|
|
13
|
-
|
|
14
|
-
case .
|
|
15
|
-
|
|
16
|
-
case .ambient:
|
|
17
|
-
.ambient
|
|
18
|
-
case .soloAmbient:
|
|
19
|
-
.soloAmbient
|
|
6
|
+
case .playback: .playback
|
|
7
|
+
case .playandrecord: .playAndRecord
|
|
8
|
+
case .multiroute: .multiRoute
|
|
9
|
+
case .ambient: .ambient
|
|
10
|
+
case .soloambient: .soloAmbient
|
|
11
|
+
case .record: .record
|
|
20
12
|
}
|
|
21
13
|
}
|
|
22
14
|
}
|
|
23
15
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
func toRouteSharingPolicy() -> AVAudioSession.RouteSharingPolicy {
|
|
16
|
+
extension IOSCategoryMode {
|
|
17
|
+
var avMode: AVAudioSession.Mode {
|
|
28
18
|
switch self {
|
|
29
|
-
case .default:
|
|
30
|
-
|
|
31
|
-
case .
|
|
32
|
-
|
|
33
|
-
case .
|
|
34
|
-
|
|
19
|
+
case .default: .default
|
|
20
|
+
case .gamechat: .gameChat
|
|
21
|
+
case .measurement: .measurement
|
|
22
|
+
case .movieplayback: .moviePlayback
|
|
23
|
+
case .spokenaudio: .spokenAudio
|
|
24
|
+
case .videochat: .videoChat
|
|
25
|
+
case .videorecording: .videoRecording
|
|
26
|
+
case .voicechat: .voiceChat
|
|
27
|
+
case .voiceprompt: .voicePrompt
|
|
35
28
|
}
|
|
36
29
|
}
|
|
37
30
|
}
|
|
38
31
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
voiceChat, voicePrompt
|
|
42
|
-
|
|
43
|
-
func mapConfigToAVAudioSessionCategoryMode() -> AVAudioSession.Mode {
|
|
32
|
+
extension IOSCategoryPolicy {
|
|
33
|
+
var avPolicy: AVAudioSession.RouteSharingPolicy {
|
|
44
34
|
switch self {
|
|
45
|
-
case .default:
|
|
46
|
-
|
|
47
|
-
case .
|
|
48
|
-
.gameChat
|
|
49
|
-
case .measurement:
|
|
50
|
-
.measurement
|
|
51
|
-
case .moviePlayback:
|
|
52
|
-
.moviePlayback
|
|
53
|
-
case .spokenAudio:
|
|
54
|
-
.spokenAudio
|
|
55
|
-
case .videoChat:
|
|
56
|
-
.videoChat
|
|
57
|
-
case .videoRecording:
|
|
58
|
-
.videoRecording
|
|
59
|
-
case .voiceChat:
|
|
60
|
-
.voiceChat
|
|
61
|
-
case .voicePrompt:
|
|
62
|
-
.voicePrompt
|
|
35
|
+
case .default: .default
|
|
36
|
+
case .longformaudio: .longFormAudio
|
|
37
|
+
case .longformvideo: .longFormVideo
|
|
63
38
|
}
|
|
64
39
|
}
|
|
65
40
|
}
|
|
66
41
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
allowBluetoothA2DP,
|
|
70
|
-
allowAirPlay,
|
|
71
|
-
defaultToSpeaker
|
|
72
|
-
|
|
73
|
-
func mapConfigToAVAudioSessionCategoryOptions() -> AVAudioSession.CategoryOptions? {
|
|
42
|
+
extension IOSCategoryOptions {
|
|
43
|
+
var avOptions: AVAudioSession.CategoryOptions {
|
|
74
44
|
switch self {
|
|
75
|
-
case .mixWithOthers
|
|
76
|
-
|
|
77
|
-
case .
|
|
78
|
-
|
|
79
|
-
case .
|
|
80
|
-
|
|
81
|
-
case .
|
|
82
|
-
.allowBluetoothHFP
|
|
83
|
-
case .allowBluetoothA2DP:
|
|
84
|
-
.allowBluetoothA2DP
|
|
85
|
-
case .allowAirPlay:
|
|
86
|
-
.allowAirPlay
|
|
87
|
-
case .defaultToSpeaker:
|
|
88
|
-
.defaultToSpeaker
|
|
45
|
+
case .mixwithothers: .mixWithOthers
|
|
46
|
+
case .duckothers: .duckOthers
|
|
47
|
+
case .interruptspokenaudioandmixwithothers: .interruptSpokenAudioAndMixWithOthers
|
|
48
|
+
case .allowbluetooth: .allowBluetoothHFP
|
|
49
|
+
case .allowbluetootha2dp: .allowBluetoothA2DP
|
|
50
|
+
case .allowairplay: .allowAirPlay
|
|
51
|
+
case .defaulttospeaker: .defaultToSpeaker
|
|
89
52
|
}
|
|
90
53
|
}
|
|
91
54
|
}
|
|
92
55
|
|
|
93
56
|
extension NativeIOSSetupOptions {
|
|
94
|
-
/// The
|
|
95
|
-
///
|
|
96
|
-
/// `categoryOptions` (e.g. `allowBluetooth` / `allowAirPlay`), `mode`, and route `policy` are
|
|
97
|
-
/// actually honoured at setup instead of being dropped.
|
|
57
|
+
/// The `AVAudioSession` configuration these options ask for; an absent field takes the
|
|
58
|
+
/// playback default.
|
|
98
59
|
func resolveAudioSessionConfig() -> (
|
|
99
60
|
category: AVAudioSession.Category,
|
|
100
61
|
mode: AVAudioSession.Mode,
|
|
101
62
|
policy: AVAudioSession.RouteSharingPolicy,
|
|
102
63
|
options: AVAudioSession.CategoryOptions,
|
|
103
64
|
) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.
|
|
109
|
-
|
|
110
|
-
let policy = self.categoryPolicy
|
|
111
|
-
.flatMap { SessionCategoryPolicy(rawValue: $0.stringValue) }?
|
|
112
|
-
.toRouteSharingPolicy() ?? .default
|
|
113
|
-
let options = (self.categoryOptions ?? [])
|
|
114
|
-
.reduce(into: AVAudioSession.CategoryOptions()) { acc, option in
|
|
115
|
-
if let mapped = SessionCategoryOptions(rawValue: option.stringValue)?
|
|
116
|
-
.mapConfigToAVAudioSessionCategoryOptions()
|
|
117
|
-
{
|
|
118
|
-
acc.insert(mapped)
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return (category, mode, policy, options)
|
|
65
|
+
(
|
|
66
|
+
category?.avCategory ?? .playback,
|
|
67
|
+
categoryMode?.avMode ?? .default,
|
|
68
|
+
categoryPolicy?.avPolicy ?? .default,
|
|
69
|
+
AVAudioSession.CategoryOptions((categoryOptions ?? []).map(\.avOptions)),
|
|
70
|
+
)
|
|
122
71
|
}
|
|
123
72
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
|
|
4
|
+
/// What `AudioSessionController` tells the player about the audio session.
|
|
5
|
+
@MainActor
|
|
6
|
+
protocol AudioSessionPlayer: AnyObject {
|
|
7
|
+
var playWhenReady: Bool { get }
|
|
8
|
+
var willResumeAfterInterruption: Bool { get }
|
|
9
|
+
func handleInterruptionBegan()
|
|
10
|
+
func handleInterruptionEnded(shouldResume: Bool)
|
|
11
|
+
func handleRouteDisconnected()
|
|
12
|
+
func mediaServicesWereReset()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Owns the app's `AVAudioSession`: its category, activation and release, and the interruption,
|
|
16
|
+
/// route-change, media-services-reset and volume observers, installed once at init.
|
|
17
|
+
///
|
|
18
|
+
/// The session is configured at setup but activated only when playback produces output:
|
|
19
|
+
/// activating a non-mixable session interrupts other apps' audio, which must not happen at
|
|
20
|
+
/// launch.
|
|
21
|
+
@MainActor
|
|
22
|
+
final class AudioSessionController {
|
|
23
|
+
weak var player: AudioSessionPlayer?
|
|
24
|
+
|
|
25
|
+
let outputChanged = Emitter<Output>()
|
|
26
|
+
let volumeChanged = Emitter<Double>()
|
|
27
|
+
|
|
28
|
+
/// Nil until `configure`: before setup the category is the host app's, not ours to restore.
|
|
29
|
+
private var configuration: (
|
|
30
|
+
category: AVAudioSession.Category,
|
|
31
|
+
mode: AVAudioSession.Mode,
|
|
32
|
+
policy: AVAudioSession.RouteSharingPolicy,
|
|
33
|
+
options: AVAudioSession.CategoryOptions,
|
|
34
|
+
)?
|
|
35
|
+
|
|
36
|
+
private var releaseWork: DispatchWorkItem?
|
|
37
|
+
|
|
38
|
+
/// Touched only by `init` and `deinit`.
|
|
39
|
+
private nonisolated(unsafe) var observers: [NSObjectProtocol] = []
|
|
40
|
+
private var volumeObservation: NSKeyValueObservation?
|
|
41
|
+
|
|
42
|
+
private var session: AVAudioSession {
|
|
43
|
+
.sharedInstance()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
init() {
|
|
47
|
+
let center = NotificationCenter.default
|
|
48
|
+
observers = [
|
|
49
|
+
center.addObserver(forName: AVAudioSession.interruptionNotification, object: nil, queue: .main) {
|
|
50
|
+
[weak self] notification in
|
|
51
|
+
let info = notification.userInfo
|
|
52
|
+
let type = info?[AVAudioSessionInterruptionTypeKey] as? UInt
|
|
53
|
+
let reason = info?[AVAudioSessionInterruptionReasonKey] as? UInt
|
|
54
|
+
let options = info?[AVAudioSessionInterruptionOptionKey] as? UInt
|
|
55
|
+
MainActor.assumeIsolated { self?.handleInterruption(type: type, reason: reason, options: options) }
|
|
56
|
+
},
|
|
57
|
+
center.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: .main) {
|
|
58
|
+
[weak self] notification in
|
|
59
|
+
let reason = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
|
|
60
|
+
MainActor.assumeIsolated { self?.handleRouteChange(reason: reason) }
|
|
61
|
+
},
|
|
62
|
+
center.addObserver(forName: AVAudioSession.mediaServicesWereResetNotification, object: nil, queue: .main) {
|
|
63
|
+
[weak self] _ in
|
|
64
|
+
MainActor.assumeIsolated {
|
|
65
|
+
// The reset cleared the category and invalidated every player and session handle.
|
|
66
|
+
self?.applyCategory()
|
|
67
|
+
self?.player?.mediaServicesWereReset()
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
volumeObservation = session.observe(\.outputVolume, options: [.new]) { [weak self] _, change in
|
|
72
|
+
guard let volume = change.newValue else { return }
|
|
73
|
+
Task { @MainActor in self?.volumeChanged.emit(Double(volume)) }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
deinit {
|
|
78
|
+
for observer in observers {
|
|
79
|
+
NotificationCenter.default.removeObserver(observer)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/// Sets the session category from the iOS setup options, without activating it.
|
|
84
|
+
func configure(_ options: NativeIOSSetupOptions?) {
|
|
85
|
+
configuration = options?.resolveAudioSessionConfig() ?? (.playback, .default, .default, [])
|
|
86
|
+
applyCategory()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Activates the session for output and cancels a pending release.
|
|
90
|
+
func activate() {
|
|
91
|
+
try? session.setActive(true)
|
|
92
|
+
cancelRelease()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/// Releases the session after 3 s unless playback resumes, so other apps' audio can come back.
|
|
96
|
+
/// Debounced: iOS dislikes deactivating while audio trails off, and a quick pause → resume
|
|
97
|
+
/// should not thrash it.
|
|
98
|
+
func scheduleRelease() {
|
|
99
|
+
releaseWork?.cancel()
|
|
100
|
+
let release = DispatchWorkItem { [weak self] in
|
|
101
|
+
guard let self, player?.playWhenReady == false else { return }
|
|
102
|
+
releaseWork = nil
|
|
103
|
+
try? session.setActive(false, options: .notifyOthersOnDeactivation)
|
|
104
|
+
}
|
|
105
|
+
releaseWork = release
|
|
106
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: release)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func cancelRelease() {
|
|
110
|
+
releaseWork?.cancel()
|
|
111
|
+
releaseWork = nil
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
nonisolated static var volume: Double {
|
|
115
|
+
Double(AVAudioSession.sharedInstance().outputVolume)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
nonisolated static var currentOutput: Output? {
|
|
119
|
+
guard let output = AVAudioSession.sharedInstance().currentRoute.outputs.first else { return nil }
|
|
120
|
+
let (type, external): (OutputType, Bool) = switch output.portType {
|
|
121
|
+
case .builtInSpeaker: (.speaker, false)
|
|
122
|
+
case .builtInReceiver: (.receiver, false)
|
|
123
|
+
case .airPlay: (.airplay, true)
|
|
124
|
+
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: (.bluetooth, true)
|
|
125
|
+
case .headphones: (.headphones, true)
|
|
126
|
+
case .carAudio: (.car, true)
|
|
127
|
+
case .HDMI: (.hdmi, true)
|
|
128
|
+
case .usbAudio: (.usb, true)
|
|
129
|
+
default: (.other, true)
|
|
130
|
+
}
|
|
131
|
+
return Output(type: type, name: output.portName, external: external)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// MARK: - Private
|
|
135
|
+
|
|
136
|
+
private func applyCategory() {
|
|
137
|
+
guard let configuration else { return }
|
|
138
|
+
try? session.setCategory(
|
|
139
|
+
configuration.category, mode: configuration.mode, policy: configuration.policy, options: configuration.options,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private func handleInterruption(type: UInt?, reason: UInt?, options: UInt?) {
|
|
144
|
+
guard let type = type.flatMap(AVAudioSession.InterruptionType.init(rawValue:)) else { return }
|
|
145
|
+
switch type {
|
|
146
|
+
case .began:
|
|
147
|
+
// iOS 17+ also begins one, never ended, when an output route disappears; the route
|
|
148
|
+
// change handles that.
|
|
149
|
+
if #available(iOS 17.0, *),
|
|
150
|
+
let reason,
|
|
151
|
+
AVAudioSession.InterruptionReason(rawValue: reason) == .routeDisconnected
|
|
152
|
+
{
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
player?.handleInterruptionBegan()
|
|
156
|
+
case .ended:
|
|
157
|
+
let shouldResume = options.map { AVAudioSession.InterruptionOptions(rawValue: $0).contains(.shouldResume) } ?? false
|
|
158
|
+
// Reactivate only when a resume follows: activating while staying paused would hold a
|
|
159
|
+
// non-mixable session with nothing to release it.
|
|
160
|
+
if shouldResume, player?.willResumeAfterInterruption == true {
|
|
161
|
+
try? session.setActive(true)
|
|
162
|
+
}
|
|
163
|
+
player?.handleInterruptionEnded(shouldResume: shouldResume)
|
|
164
|
+
@unknown default:
|
|
165
|
+
break
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private func handleRouteChange(reason: UInt?) {
|
|
170
|
+
// The output disappeared (headphones unplugged, a speaker off): pause, as every media app
|
|
171
|
+
// does, rather than carry on from the built-in speaker.
|
|
172
|
+
if let reason, AVAudioSession.RouteChangeReason(rawValue: reason) == .oldDeviceUnavailable {
|
|
173
|
+
player?.handleRouteDisconnected()
|
|
174
|
+
}
|
|
175
|
+
if let output = Self.currentOutput { outputChanged.emit(output) }
|
|
176
|
+
}
|
|
177
|
+
}
|