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
|
@@ -19,18 +19,22 @@ internal class ServiceBinding(private val context: Context) {
|
|
|
19
19
|
var isBound: Boolean = false
|
|
20
20
|
private set
|
|
21
21
|
|
|
22
|
+
@Volatile private var bindSucceeded: Boolean = false
|
|
23
|
+
|
|
22
24
|
/**
|
|
23
|
-
* Binds if not already bound, and returns whether
|
|
24
|
-
*
|
|
25
|
+
* Binds if not already bound, and returns whether the service will connect: what
|
|
26
|
+
* [Context.bindService] returned for the binding held. A second call while bound is a no-op that
|
|
27
|
+
* returns the same answer: Android would otherwise count the extra bind and expect a matching
|
|
25
28
|
* unbind.
|
|
26
29
|
*/
|
|
27
30
|
fun bind(intent: Intent, connection: ServiceConnection): Boolean {
|
|
28
|
-
if (isBound) return
|
|
31
|
+
if (isBound) return bindSucceeded
|
|
29
32
|
// Marked bound whatever the result: Android registers the connection before it asks the
|
|
30
33
|
// ActivityManager, so a false return still leaves one to release — "regardless of the return
|
|
31
34
|
// value, you should later call unbindService()".
|
|
32
35
|
isBound = true
|
|
33
|
-
|
|
36
|
+
bindSucceeded = context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
|
|
37
|
+
return bindSucceeded
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
/**
|
|
@@ -14,10 +14,9 @@ import timber.log.Timber
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Outbound URL resolution for media and artwork — the Android analog of iOS
|
|
17
|
-
* `
|
|
18
|
-
* request
|
|
19
|
-
*
|
|
20
|
-
* [RequestConfigBuilder].
|
|
17
|
+
* `RequestPipeline.swift`. Owns how a Track's `src` / `artwork` becomes a fetchable request:
|
|
18
|
+
* request layer (shared, incl. its transform) → kind config (media / artwork / nowPlayingArtwork) →
|
|
19
|
+
* per-Track Resolve, with transform-wins semantics centralised in [RequestConfigBuilder].
|
|
21
20
|
*/
|
|
22
21
|
|
|
23
22
|
/**
|
|
@@ -74,6 +74,19 @@ class ServiceBindingTest {
|
|
|
74
74
|
assertEquals(1, shadowOf(app).unboundServiceConnections.size)
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* setupPlayer binds after the auto-bind at construction already has. If that first bind failed,
|
|
79
|
+
* the service never connects, so setup must hear false and reject rather than wait for it.
|
|
80
|
+
*/
|
|
81
|
+
@Test
|
|
82
|
+
fun bindingAgainAfterAFailedBindReportsTheFailure() {
|
|
83
|
+
val binding = ServiceBinding(app)
|
|
84
|
+
shadowOf(app).declareActionUnbindable("unbindable")
|
|
85
|
+
|
|
86
|
+
assertFalse(binding.bind(Intent("unbindable"), connection))
|
|
87
|
+
assertFalse(binding.bind(Intent("unbindable"), connection))
|
|
88
|
+
}
|
|
89
|
+
|
|
77
90
|
@Test
|
|
78
91
|
fun unbindIsIdempotent() {
|
|
79
92
|
val binding = ServiceBinding(app)
|
|
@@ -15,8 +15,8 @@ import org.junit.Test
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Tests browser/BrowserUrlResolution.kt — media/artwork URL resolution owned by BrowserManager (the
|
|
18
|
-
* Kotlin analog of iOS
|
|
19
|
-
*
|
|
18
|
+
* Kotlin analog of iOS RequestPipeline.swift). Static configs only (Nitro Promises are JNI-backed;
|
|
19
|
+
* see file-top note in BrowserManagerLayerResolutionTest).
|
|
20
20
|
*/
|
|
21
21
|
class BrowserUrlResolutionTest {
|
|
22
22
|
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
@preconcurrency import AVFoundation
|
|
2
|
+
import Foundation
|
|
3
|
+
import MediaPlayer
|
|
4
|
+
import NitroModules
|
|
5
|
+
import os.log
|
|
6
|
+
|
|
7
|
+
/// The `PlayerEngine` over AVFoundation: the AVPlayer and its observers, media loading, the
|
|
8
|
+
/// now-playing session and the remote commands. Carries out what the `Player` decides; holds
|
|
9
|
+
/// no playback policy.
|
|
10
|
+
@MainActor
|
|
11
|
+
final class AVPlayerEngine: PlayerEngine {
|
|
12
|
+
let logger = Logger(subsystem: "com.audiobrowser", category: "AVPlayerEngine")
|
|
13
|
+
|
|
14
|
+
var onEvent: (@MainActor (EngineEvent) -> Void)?
|
|
15
|
+
private weak var callbacks: AVPlayerEngineCallbacks?
|
|
16
|
+
|
|
17
|
+
let nowPlayingInfoController: NowPlayingInfoController
|
|
18
|
+
let nowPlayingUpdater: NowPlayingUpdater
|
|
19
|
+
let remoteCommandController: RemoteCommandController
|
|
20
|
+
private let mediaLoader = MediaLoader()
|
|
21
|
+
private let silentAudioPrimer = SilentAudioPrimer()
|
|
22
|
+
|
|
23
|
+
private var avPlayer = AVPlayer()
|
|
24
|
+
private var playerObserver: PlayerObserver?
|
|
25
|
+
private var itemObserver: ItemObserver?
|
|
26
|
+
private var rate: Float = 1
|
|
27
|
+
private var skipAvailability = (canNext: false, canPrevious: false)
|
|
28
|
+
|
|
29
|
+
var remoteCommands: [RemoteCommand] = [] {
|
|
30
|
+
didSet { remoteCommandController.enable(commands: remoteCommands) }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// - Parameter resolver: Resolves media URLs and now-playing artwork.
|
|
34
|
+
init(
|
|
35
|
+
callbacks: AVPlayerEngineCallbacks?,
|
|
36
|
+
resolver: MediaResolving?,
|
|
37
|
+
nowPlayingInfoController: NowPlayingInfoController = NowPlayingInfoController(),
|
|
38
|
+
) {
|
|
39
|
+
self.callbacks = callbacks
|
|
40
|
+
self.nowPlayingInfoController = nowPlayingInfoController
|
|
41
|
+
nowPlayingUpdater = NowPlayingUpdater(nowPlayingInfoController: nowPlayingInfoController)
|
|
42
|
+
remoteCommandController = RemoteCommandController(callbacks: callbacks)
|
|
43
|
+
|
|
44
|
+
mediaLoader.onEvent = { [weak self] in self?.mediaLoaderDidEmit($0) }
|
|
45
|
+
mediaLoader.mediaUrlResolver = { [weak resolver] src, track in
|
|
46
|
+
await resolver?.resolveMediaUrl(src, track: track) ?? MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
47
|
+
}
|
|
48
|
+
nowPlayingUpdater.artworkUrlResolver = { [weak resolver] track, imageContext in
|
|
49
|
+
await resolver?.resolveNowPlayingArtwork(track: track, imageContext: imageContext)
|
|
50
|
+
}
|
|
51
|
+
nowPlayingUpdater.onChanged = { [weak self] in self?.callbacks?.playerDidChangeNowPlaying($0) }
|
|
52
|
+
remoteCommandController.skipAvailability = { [weak self] in
|
|
53
|
+
self?.skipAvailability ?? (false, false)
|
|
54
|
+
}
|
|
55
|
+
setUpAVPlayer()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Releases the player for good: the now-playing session, the observers and the remote
|
|
59
|
+
/// commands go with it.
|
|
60
|
+
func destroy() {
|
|
61
|
+
remoteCommandController.disableAll()
|
|
62
|
+
nowPlayingInfoController.unlinkPlayer()
|
|
63
|
+
playerObserver?.invalidate()
|
|
64
|
+
playerObserver = nil
|
|
65
|
+
stopObservingItem()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/// Re-requests the now-playing election, which the system may have declined at link time while
|
|
69
|
+
/// the audio session was still inactive.
|
|
70
|
+
func reactivateNowPlayingSession() {
|
|
71
|
+
nowPlayingInfoController.reactivateSessionIfNeeded()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// MARK: - Buffering
|
|
75
|
+
|
|
76
|
+
var bufferDuration: Double = 0 {
|
|
77
|
+
didSet {
|
|
78
|
+
avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
|
|
79
|
+
mediaLoader.bufferDuration = bufferDuration
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// MARK: - The Current Item
|
|
84
|
+
|
|
85
|
+
var hasItem: Bool {
|
|
86
|
+
avPlayer.currentItem != nil
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
var hasLoadedAsset: Bool {
|
|
90
|
+
mediaLoader.asset != nil
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
var currentTime: Double {
|
|
94
|
+
let seconds = avPlayer.currentTime().seconds
|
|
95
|
+
return seconds.isNaN ? 0 : seconds
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// The current item's duration, or 0 when it has none (a live stream). Not
|
|
99
|
+
/// `item.asset.duration`: deprecated since iOS 16, and it blocks the main actor fetching a
|
|
100
|
+
/// remote asset's duration.
|
|
101
|
+
var duration: Double {
|
|
102
|
+
guard let item = avPlayer.currentItem else { return 0 }
|
|
103
|
+
if !item.duration.seconds.isNaN {
|
|
104
|
+
return item.duration.seconds
|
|
105
|
+
}
|
|
106
|
+
if let seekable = item.seekableTimeRanges.last?.timeRangeValue.duration.seconds, !seekable.isNaN {
|
|
107
|
+
return seekable
|
|
108
|
+
}
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var bufferedPosition: Double {
|
|
113
|
+
avPlayer.currentItem?.loadedTimeRanges.last?.timeRangeValue.end.seconds ?? 0
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
var resumePosition: Double? {
|
|
117
|
+
guard let item = avPlayer.currentItem, !item.duration.isIndefinite else { return nil }
|
|
118
|
+
return item.currentTime().seconds
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
var liveEdge: Double? {
|
|
122
|
+
guard let range = avPlayer.currentItem?.seekableTimeRanges.last?.timeRangeValue,
|
|
123
|
+
range.duration.seconds > 0
|
|
124
|
+
else { return nil }
|
|
125
|
+
return range.end.seconds
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
var isPlaybackLikelyToKeepUp: Bool {
|
|
129
|
+
avPlayer.currentItem?.isPlaybackLikelyToKeepUp ?? false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
var isAirPlayRoute: Bool {
|
|
133
|
+
AVAudioSession.sharedInstance().currentRoute.outputs.contains { $0.portType == .airPlay }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
var volume: Float {
|
|
137
|
+
get { avPlayer.volume }
|
|
138
|
+
set { avPlayer.volume = newValue }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// MARK: - Loading
|
|
142
|
+
|
|
143
|
+
func resolveAndLoad(src: String, track: Track) {
|
|
144
|
+
mediaLoader.resolveAndLoad(src: src, track: track)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
func loadResolved() {
|
|
148
|
+
mediaLoader.loadAsset()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
func cancelLoading() {
|
|
152
|
+
mediaLoader.cancelAll()
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
func dropAsset() {
|
|
156
|
+
stopObservingItem()
|
|
157
|
+
mediaLoader.clearAsset()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
func clearItem() {
|
|
161
|
+
dropAsset()
|
|
162
|
+
avPlayer.replaceCurrentItem(with: nil)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
func stopObservingItem() {
|
|
166
|
+
itemObserver?.invalidate()
|
|
167
|
+
itemObserver = nil
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func recreate() {
|
|
171
|
+
playerObserver?.invalidate()
|
|
172
|
+
nowPlayingInfoController.unlinkPlayer()
|
|
173
|
+
avPlayer = AVPlayer()
|
|
174
|
+
setUpAVPlayer()
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private func setUpAVPlayer() {
|
|
178
|
+
avPlayer.allowsExternalPlayback = false
|
|
179
|
+
avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
|
|
180
|
+
playerObserver = PlayerObserver(player: avPlayer) { [weak self] in self?.playerDidEmit($0) }
|
|
181
|
+
remoteCommandController.switchCommandCenter(nowPlayingInfoController.linkPlayer(avPlayer))
|
|
182
|
+
avPlayer.defaultRate = rate
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// MARK: - Transport
|
|
186
|
+
|
|
187
|
+
func play() {
|
|
188
|
+
avPlayer.play()
|
|
189
|
+
if rate != 1 { avPlayer.rate = rate }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func pause() {
|
|
193
|
+
avPlayer.pause()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
func setRate(_ rate: Float, playing: Bool) {
|
|
197
|
+
self.rate = rate
|
|
198
|
+
avPlayer.defaultRate = rate
|
|
199
|
+
if playing { avPlayer.rate = rate }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func seek(to seconds: Double, completion: @escaping @MainActor (Bool) -> Void) {
|
|
203
|
+
avPlayer.seek(
|
|
204
|
+
to: CMTime(seconds: seconds, preferredTimescale: 1000), toleranceBefore: .zero, toleranceAfter: .zero,
|
|
205
|
+
) { finished in
|
|
206
|
+
Task { @MainActor in completion(finished) }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
func applyTimePitchAlgorithm() {
|
|
211
|
+
avPlayer.currentItem?.audioTimePitchAlgorithm = .timeDomain
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// MARK: - Now Playing and Remote Commands
|
|
215
|
+
|
|
216
|
+
func primeNowPlaying() {
|
|
217
|
+
silentAudioPrimer.prime()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func loadNowPlayingMetadata(for track: Track) {
|
|
221
|
+
nowPlayingUpdater.loadMetaValues(for: track)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func clearNowPlaying() {
|
|
225
|
+
nowPlayingUpdater.clearNowPlaying()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func updateRemoteRepeatMode(_ mode: RepeatMode) {
|
|
229
|
+
remoteCommandController.updateRepeatMode(mode)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
func updateRemoteShuffleMode(_ enabled: Bool) {
|
|
233
|
+
remoteCommandController.updateShuffleMode(enabled)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
|
|
237
|
+
skipAvailability = (canNext, canPrevious)
|
|
238
|
+
remoteCommandController.refreshSkipAvailability()
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// MARK: - Observed Events
|
|
242
|
+
|
|
243
|
+
private func playerDidEmit(_ event: PlayerObserver.Event) {
|
|
244
|
+
switch event {
|
|
245
|
+
case let .status(status):
|
|
246
|
+
if status == .failed { reportFailure(nil) }
|
|
247
|
+
case let .timeControlStatus(status):
|
|
248
|
+
let mapped: PlayerTimeControlStatus
|
|
249
|
+
switch status {
|
|
250
|
+
case .paused: mapped = .paused
|
|
251
|
+
case .waitingToPlayAtSpecifiedRate: mapped = .waitingToPlayAtSpecifiedRate
|
|
252
|
+
case .playing: mapped = .playing
|
|
253
|
+
@unknown default: return
|
|
254
|
+
}
|
|
255
|
+
onEvent?(.timeControlStatus(mapped, waitingForNoItem: avPlayer.reasonForWaitingToPlay == .noItemToPlay))
|
|
256
|
+
case .audioDidStart:
|
|
257
|
+
onEvent?(.audioDidStart)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private func itemDidEmit(_ event: ItemObserver.Event) {
|
|
262
|
+
switch event {
|
|
263
|
+
case let .likelyToKeepUp(isLikely): onEvent?(.likelyToKeepUp(isLikely))
|
|
264
|
+
case let .failed(error), let .failedToPlayToEnd(error): reportFailure(error)
|
|
265
|
+
case .playedToEnd: onEvent?(.playedToEnd)
|
|
266
|
+
case .stalled: onEvent?(.stalled)
|
|
267
|
+
case let .timedMetadata(groups): callbacks?.playerDidReceiveTimedMetadata(groups)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/// The item's own error stands in when the report carried none.
|
|
272
|
+
private func reportFailure(_ error: Error?) {
|
|
273
|
+
onEvent?(.failed(error ?? avPlayer.currentItem?.error, httpStatusCode: currentItemHttpStatusCode))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// The HTTP status the server last answered the current item with. `AVPlayerItem.error` never
|
|
277
|
+
/// carries it (a 404 arrives as an opaque CoreMedia error), so the per-item error log is the
|
|
278
|
+
/// only place it surfaces.
|
|
279
|
+
private var currentItemHttpStatusCode: Int? {
|
|
280
|
+
guard let events = avPlayer.currentItem?.errorLog()?.events else { return nil }
|
|
281
|
+
return PlaybackFailure.httpStatusCode(fromErrorStatusCodes: events.map(\.errorStatusCode))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func mediaLoaderDidEmit(_ event: MediaLoader.Event) {
|
|
285
|
+
switch event {
|
|
286
|
+
case let .prepared(item):
|
|
287
|
+
nowPlayingInfoController.prepareItem(item)
|
|
288
|
+
avPlayer.replaceCurrentItem(with: item)
|
|
289
|
+
itemObserver?.invalidate()
|
|
290
|
+
itemObserver = ItemObserver(item: item) { [weak self] in self?.itemDidEmit($0) }
|
|
291
|
+
onEvent?(.itemPrepared(likelyToKeepUp: item.isPlaybackLikelyToKeepUp))
|
|
292
|
+
case let .failed(error):
|
|
293
|
+
attachPublishingCarrierItemIfNeeded()
|
|
294
|
+
onEvent?(.loadFailed(error))
|
|
295
|
+
case .unplayable:
|
|
296
|
+
attachPublishingCarrierItemIfNeeded()
|
|
297
|
+
onEvent?(.unplayable)
|
|
298
|
+
case let .invalidSource(url):
|
|
299
|
+
// No carrier: an invalid src never produced an asset.
|
|
300
|
+
onEvent?(.invalidSource(url))
|
|
301
|
+
case let .commonMetadata(items):
|
|
302
|
+
callbacks?.playerDidReceiveCommonMetadata(items)
|
|
303
|
+
case let .chapterMetadata(groups):
|
|
304
|
+
callbacks?.playerDidReceiveChapterMetadata(groups)
|
|
305
|
+
case let .timedMetadata(groups):
|
|
306
|
+
callbacks?.playerDidReceiveTimedMetadata(groups)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// `MPNowPlayingSession` publishes only through the linked player's current item, so a load
|
|
311
|
+
/// that fails before item creation (a dead host on a cold start) leaves nothing to surface on
|
|
312
|
+
/// the lock screen or CarPlay. The failed asset's item is attached purely as a publishing
|
|
313
|
+
/// carrier, unobserved, so its `.failed` status cannot re-report the loader's error. A
|
|
314
|
+
/// successful load replaces it; a retry's reload leaves it in place, so the surface doesn't
|
|
315
|
+
/// flicker.
|
|
316
|
+
private func attachPublishingCarrierItemIfNeeded() {
|
|
317
|
+
guard avPlayer.currentItem == nil, let asset = mediaLoader.asset else { return }
|
|
318
|
+
logger.notice("Attaching publishing-carrier item for failed load")
|
|
319
|
+
let item = AVPlayerItem(asset: asset)
|
|
320
|
+
nowPlayingInfoController.prepareItem(item)
|
|
321
|
+
avPlayer.replaceCurrentItem(with: item)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
extension Player: AudioSessionPlayer {}
|
|
326
|
+
|
|
327
|
+
extension Player: NowPlayingSource {}
|
|
328
|
+
|
|
329
|
+
/// Resolves the URLs the player fetches.
|
|
330
|
+
@MainActor
|
|
331
|
+
protocol MediaResolving: AnyObject {
|
|
332
|
+
func resolveMediaUrl(_ src: String, track: Track?) async -> MediaResolvedUrl
|
|
333
|
+
func resolveNowPlayingArtwork(track: Track, imageContext: ImageContext?) async -> ImageSource?
|
|
334
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import MediaPlayer
|
|
3
|
+
import NitroModules
|
|
4
|
+
|
|
5
|
+
/// What the AVPlayerEngine reports: the remote commands, and the metadata and now-playing
|
|
6
|
+
/// changes, which carry AVFoundation types. HybridAudioBrowser implements it. The method names
|
|
7
|
+
/// are prefixed so they don't collide with the JS callback properties.
|
|
8
|
+
@MainActor
|
|
9
|
+
protocol AVPlayerEngineCallbacks: RemoteCommandCallbacks {
|
|
10
|
+
func playerDidReceiveCommonMetadata(_ metadata: [AVMetadataItem])
|
|
11
|
+
func playerDidReceiveTimedMetadata(_ metadata: [AVTimedMetadataGroup])
|
|
12
|
+
func playerDidReceiveChapterMetadata(_ metadata: [AVTimedMetadataGroup])
|
|
13
|
+
/// The now-playing lines or track changed; carries the metadata JS sees.
|
|
14
|
+
func playerDidChangeNowPlaying(_ metadata: NowPlayingMetadata)
|
|
15
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import NitroModules
|
|
2
|
+
|
|
3
|
+
/// The Browse Gate: an app-imposed block on browsing and searching from external surfaces,
|
|
4
|
+
/// decided per request. It never blocks the Player, the Queue or Now Playing.
|
|
5
|
+
@MainActor
|
|
6
|
+
final class BrowseGate {
|
|
7
|
+
enum Mode {
|
|
8
|
+
case off
|
|
9
|
+
/// Every request is gated, with this chrome or the built-in one.
|
|
10
|
+
case fixed(Gate?)
|
|
11
|
+
/// JS decides each request; `default` is the chrome when its decision carries none.
|
|
12
|
+
case resolved(default: Gate?)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/// Served when a request is gated with no chrome from JS or `setGate`.
|
|
16
|
+
static let builtInChrome = Gate(title: "Unavailable", message: nil)
|
|
17
|
+
|
|
18
|
+
private(set) var mode: Mode = .off
|
|
19
|
+
|
|
20
|
+
/// Fires `isActive` on every set and clear; chrome is per request, so surfaces re-render.
|
|
21
|
+
let changed = Emitter<Bool>()
|
|
22
|
+
|
|
23
|
+
/// Asks JS for a decision; set by the bridge, whose JS resolver it calls.
|
|
24
|
+
var resolve: @MainActor (NativeGateRequest) async throws -> GateDecision = { _ in throw CancellationError() }
|
|
25
|
+
|
|
26
|
+
nonisolated init() {}
|
|
27
|
+
|
|
28
|
+
var isActive: Bool {
|
|
29
|
+
if case .off = mode { false } else { true }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func set(chrome: Gate?, hasResolver: Bool) {
|
|
33
|
+
mode = hasResolver ? .resolved(default: chrome) : .fixed(chrome)
|
|
34
|
+
changed.emit(true)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func clear() {
|
|
38
|
+
guard isActive else { return }
|
|
39
|
+
mode = .off
|
|
40
|
+
changed.emit(false)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/// The chrome to show if `request` is gated; nil when it is allowed. A resolver that fails
|
|
44
|
+
/// cannot prove the request allowed, so it is gated: a gate exists to withhold content, and
|
|
45
|
+
/// serving on an error would leak it.
|
|
46
|
+
func decide(_ request: NativeGateRequest) async -> Gate? {
|
|
47
|
+
switch mode {
|
|
48
|
+
case .off:
|
|
49
|
+
return nil
|
|
50
|
+
case let .fixed(chrome):
|
|
51
|
+
return chrome ?? Self.builtInChrome
|
|
52
|
+
case let .resolved(defaultChrome):
|
|
53
|
+
guard let decision = try? await resolve(request) else { return defaultChrome ?? Self.builtInChrome }
|
|
54
|
+
guard decision.gated else { return nil }
|
|
55
|
+
return decision.gate ?? defaultChrome ?? Self.builtInChrome
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -1,97 +1,64 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import NitroModules
|
|
3
|
-
import os.log
|
|
4
3
|
|
|
5
|
-
///
|
|
6
|
-
///
|
|
7
|
-
///
|
|
8
|
-
/// provides easier access to configuration properties.
|
|
4
|
+
/// The browser configuration, copied out of `NativeBrowserConfiguration` once per
|
|
5
|
+
/// `configureBrowser`: the generated getters convert from C++ on every read, and routes are read
|
|
6
|
+
/// on every resolve. Matches Kotlin's BrowserConfig.
|
|
9
7
|
struct BrowserConfig {
|
|
10
8
|
/// Base HTTP request configuration (applied to every request kind)
|
|
11
|
-
|
|
9
|
+
var request: TransformableRequestConfig?
|
|
12
10
|
|
|
13
11
|
/// Browse-kind request configuration (applied to every browse request,
|
|
14
12
|
/// layered request → browse → route)
|
|
15
|
-
|
|
13
|
+
var browse: TransformableRequestConfig?
|
|
16
14
|
|
|
17
15
|
/// Resolver for the shared request layer (resolved once per content generation).
|
|
18
|
-
|
|
16
|
+
var requestResolver: (() -> Promise<Promise<TransformableRequestConfig>>)?
|
|
19
17
|
|
|
20
18
|
/// Resolver for the browse layer (resolved once per content generation).
|
|
21
|
-
|
|
19
|
+
var browseResolver: (() -> Promise<Promise<TransformableRequestConfig>>)?
|
|
22
20
|
|
|
23
21
|
/// Media URL transformation configuration
|
|
24
|
-
|
|
22
|
+
var media: MediaRequestConfig?
|
|
25
23
|
|
|
26
24
|
/// Artwork URL transformation configuration
|
|
27
|
-
|
|
25
|
+
var artwork: ArtworkRequestConfig?
|
|
28
26
|
|
|
29
27
|
/// Now-playing-only artwork configuration (lock screen / CarPlay / Android Auto
|
|
30
28
|
/// now-playing). A distinct kind from `artwork`; the now-playing path falls back to
|
|
31
29
|
/// `artwork` when this is nil.
|
|
32
|
-
|
|
30
|
+
var nowPlayingArtwork: ArtworkRequestConfig?
|
|
33
31
|
|
|
34
32
|
/// Routes as array with flattened entries
|
|
35
33
|
/// Includes __tabs__, __search__, and __default__ special routes
|
|
36
|
-
|
|
34
|
+
var routes: [NativeRouteEntry]?
|
|
37
35
|
|
|
38
36
|
/// Behavior: single track playback vs queue expansion
|
|
39
|
-
|
|
37
|
+
var singleTrack: Bool = false
|
|
40
38
|
|
|
41
39
|
/// Behavior: show offline error in Android Auto controller
|
|
42
|
-
|
|
40
|
+
var androidControllerOfflineError: Bool = true
|
|
43
41
|
|
|
44
42
|
// MARK: - CarPlay Options
|
|
45
43
|
|
|
46
44
|
/// App-localized title for CarPlay loading screens (shown as the centered
|
|
47
45
|
/// empty state while content resolves; nil leaves them blank)
|
|
48
|
-
|
|
46
|
+
var carPlayLoadingTitle: String?
|
|
49
47
|
|
|
50
48
|
/// Resolves a browse path for the CarPlay Now Playing album line when the
|
|
51
49
|
/// active track has no albumPath. Invoked on track changes (not at tap) so
|
|
52
50
|
/// the album line only becomes tappable when a destination exists.
|
|
53
|
-
|
|
51
|
+
var resolveAlbumPath: ((_ track: Track) -> Promise<String?>)?
|
|
54
52
|
|
|
55
53
|
/// Custom handler for track load events (overrides default load behavior)
|
|
56
|
-
|
|
54
|
+
var handleTrackLoad: ((_ event: TrackLoadEvent) -> Promise<Promise<Void>>)?
|
|
57
55
|
|
|
58
56
|
/// Callback to customize navigation error display (for i18n)
|
|
59
57
|
/// Used by CarPlay and available via `useFormattedNavigationError()` for app UI.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
init(
|
|
63
|
-
request: TransformableRequestConfig? = nil,
|
|
64
|
-
browse: TransformableRequestConfig? = nil,
|
|
65
|
-
requestResolver: (() -> Promise<Promise<TransformableRequestConfig>>)? = nil,
|
|
66
|
-
browseResolver: (() -> Promise<Promise<TransformableRequestConfig>>)? = nil,
|
|
67
|
-
media: MediaRequestConfig? = nil,
|
|
68
|
-
artwork: ArtworkRequestConfig? = nil,
|
|
69
|
-
nowPlayingArtwork: ArtworkRequestConfig? = nil,
|
|
70
|
-
routes: [NativeRouteEntry]? = nil,
|
|
71
|
-
singleTrack: Bool = false,
|
|
72
|
-
handleTrackLoad: ((_ event: TrackLoadEvent) -> Promise<Promise<Void>>)? = nil,
|
|
73
|
-
androidControllerOfflineError: Bool = true,
|
|
74
|
-
carPlayLoadingTitle: String? = nil,
|
|
75
|
-
resolveAlbumPath: ((_ track: Track) -> Promise<String?>)? = nil,
|
|
76
|
-
formatNavigationError: ((_ params: FormatNavigationErrorParams) -> Promise<FormattedNavigationError?>)? = nil,
|
|
77
|
-
) {
|
|
78
|
-
self.request = request
|
|
79
|
-
self.browse = browse
|
|
80
|
-
self.requestResolver = requestResolver
|
|
81
|
-
self.browseResolver = browseResolver
|
|
82
|
-
self.media = media
|
|
83
|
-
self.artwork = artwork
|
|
84
|
-
self.nowPlayingArtwork = nowPlayingArtwork
|
|
85
|
-
self.routes = routes
|
|
86
|
-
self.singleTrack = singleTrack
|
|
87
|
-
self.handleTrackLoad = handleTrackLoad
|
|
88
|
-
self.androidControllerOfflineError = androidControllerOfflineError
|
|
89
|
-
self.carPlayLoadingTitle = carPlayLoadingTitle
|
|
90
|
-
self.resolveAlbumPath = resolveAlbumPath
|
|
91
|
-
self.formatNavigationError = formatNavigationError
|
|
92
|
-
}
|
|
58
|
+
var formatNavigationError: ((_ params: FormatNavigationErrorParams) -> Promise<FormattedNavigationError?>)?
|
|
59
|
+
|
|
60
|
+
init() {}
|
|
93
61
|
|
|
94
|
-
/// Create from NativeBrowserConfiguration
|
|
95
62
|
init(from config: NativeBrowserConfiguration) {
|
|
96
63
|
request = config.request
|
|
97
64
|
browse = config.browse
|
|
@@ -117,43 +84,18 @@ struct BrowserConfig {
|
|
|
117
84
|
/// no-content path). See `HybridAudioBrowser.dropStaleJSCallbacks` for why
|
|
118
85
|
/// invoking a stale callback is fatal.
|
|
119
86
|
func strippingJSCallbacks() -> BrowserConfig {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
// MARK: - Track Load Handler
|
|
134
|
-
|
|
135
|
-
private static let logger = Logger(subsystem: "com.audiobrowser", category: "BrowserConfig")
|
|
136
|
-
|
|
137
|
-
/// Awaits the handleTrackLoad callback using the double-Promise pattern required by Nitro.
|
|
138
|
-
///
|
|
139
|
-
/// Nitro wraps value-returning JS callbacks in Promise<T> for thread safety, so a JS callback
|
|
140
|
-
/// returning Promise<void> becomes Promise<Promise<Void>> on the native side. Both layers
|
|
141
|
-
/// must be awaited to properly wait for the JS work to complete.
|
|
142
|
-
///
|
|
143
|
-
/// - Returns: true if the handler was present and invoked, false if not configured
|
|
144
|
-
func awaitTrackLoadHandler(event: TrackLoadEvent) async -> Bool {
|
|
145
|
-
guard let handler = handleTrackLoad else { return false }
|
|
146
|
-
do {
|
|
147
|
-
// MainActor: Nitro bridge call must be on main thread (C++ noexcept)
|
|
148
|
-
nonisolated(unsafe) let _handler = handler
|
|
149
|
-
nonisolated(unsafe) let _event = event
|
|
150
|
-
let outerPromise = await MainActor.run { _handler(_event) }
|
|
151
|
-
let innerPromise = try await outerPromise.await()
|
|
152
|
-
try await innerPromise.await()
|
|
153
|
-
} catch {
|
|
154
|
-
Self.logger.error("handleTrackLoad failed: \(error.localizedDescription)")
|
|
155
|
-
}
|
|
156
|
-
return true
|
|
87
|
+
// Built up from empty, so a field added later is dropped unless it is copied here.
|
|
88
|
+
var stripped = BrowserConfig()
|
|
89
|
+
stripped.request = request?.strippingJSCallbacks()
|
|
90
|
+
stripped.browse = browse?.strippingJSCallbacks()
|
|
91
|
+
stripped.media = media?.strippingJSCallbacks()
|
|
92
|
+
stripped.artwork = artwork?.strippingJSCallbacks()
|
|
93
|
+
stripped.nowPlayingArtwork = nowPlayingArtwork?.strippingJSCallbacks()
|
|
94
|
+
stripped.routes = routes?.map { $0.strippingJSCallbacks() }
|
|
95
|
+
stripped.singleTrack = singleTrack
|
|
96
|
+
stripped.androidControllerOfflineError = androidControllerOfflineError
|
|
97
|
+
stripped.carPlayLoadingTitle = carPlayLoadingTitle
|
|
98
|
+
return stripped
|
|
157
99
|
}
|
|
158
100
|
}
|
|
159
101
|
|