react-native-audio-browser 0.2.0-next.32 → 0.2.0-next.34
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/ios/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- package/ios/Browser/BrowserConfig.swift +0 -5
- package/ios/Browser/BrowserError.swift +16 -0
- package/ios/Browser/BrowserManager.swift +36 -141
- package/ios/Browser/BrowserPathHelper.swift +25 -12
- package/ios/Browser/FavoriteSet.swift +46 -0
- package/ios/Browser/JsonModels.swift +79 -199
- package/ios/Browser/PageResolver.swift +16 -96
- package/ios/Browser/RequestLayer.swift +97 -2
- package/ios/Browser/RequestPipeline.swift +78 -227
- package/ios/Browser/SectionScope.swift +25 -0
- package/ios/Browser/SimpleRouter.swift +73 -144
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
- package/ios/CarPlay/CarPlaySession.swift +3 -3
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- package/ios/Extension/Capability+RemoteCommand.swift +22 -69
- package/ios/Extension/SearchParams+Query.swift +28 -0
- package/ios/Extension/Track+AVPlayer.swift +0 -16
- package/ios/Extension/Track+Copying.swift +3 -1
- package/ios/Extension/Track+Identity.swift +7 -0
- package/ios/Http/HttpClient.swift +12 -30
- package/ios/HybridAudioBrowser+Browser.swift +21 -38
- package/ios/HybridAudioBrowser+Options.swift +27 -66
- package/ios/HybridAudioBrowser+Player.swift +4 -60
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
- package/ios/HybridAudioBrowser.swift +36 -99
- package/ios/Model/NitroTypeStubs.swift +80 -0
- package/ios/Model/PlayerUpdateOptions.swift +2 -0
- package/ios/Model/RemoteCommand.swift +6 -78
- package/ios/Player/NowPlayingUpdater.swift +13 -7
- package/ios/Player/Player.swift +26 -10
- package/ios/Player/PlayerCallbacks.swift +1 -4
- package/ios/Player/PlayerEngine.swift +17 -0
- package/ios/Player/RetryLoop.swift +5 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
- package/ios/Tests/BrowserPathHelperTests.swift +33 -0
- package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/FavoriteSetTests.swift +60 -0
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +95 -17
- package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
- package/ios/Tests/RequestLayerTests.swift +116 -0
- package/ios/Tests/SearchParamsQueryTests.swift +48 -0
- package/ios/Tests/SectionScopeTests.swift +53 -0
- package/ios/Tests/SimpleRouterTests.swift +38 -3
- package/ios/Tests/Support/TestTypes.swift +44 -3
- package/ios/TrackSelector.swift +34 -52
- package/ios/Util/Emitter.swift +0 -14
- package/ios/Util/NetworkMonitor.swift +8 -25
- package/ios/Util/OutputPicker.swift +48 -0
- package/package.json +1 -1
- package/ios/Model/SourceType.swift +0 -6
- package/ios/Tests/RemoteCommandTests.swift +0 -65
|
@@ -10,12 +10,16 @@ public extension HybridAudioBrowser {
|
|
|
10
10
|
/// synchronous: JS hears it before the navigation that may set the error again.
|
|
11
11
|
internal func navigate(_ path: String, clearError: Bool) {
|
|
12
12
|
if clearError { onMainActor { navigationError.clear() } }
|
|
13
|
-
Task { @MainActor in
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
Task { @MainActor in await loadPath(path) }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/// Loads `path` into the browser, reporting a failure as the navigation error.
|
|
17
|
+
@MainActor
|
|
18
|
+
private func loadPath(_ path: String) async {
|
|
19
|
+
do {
|
|
20
|
+
try await browserManager.load(path)
|
|
21
|
+
} catch {
|
|
22
|
+
await reportNavigationError(error, path: path)
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
|
|
@@ -33,9 +37,7 @@ public extension HybridAudioBrowser {
|
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
func navigateTrack(track: Track) throws {
|
|
36
|
-
// @MainActor: `player` is main-confined
|
|
37
|
-
// MainActor anyway — a bare Task read the reference off the cooperative
|
|
38
|
-
// pool, racing the assignment in `setupPlayer`.
|
|
40
|
+
// @MainActor: `player` is main-confined.
|
|
39
41
|
Task { @MainActor in
|
|
40
42
|
guard let player else { return }
|
|
41
43
|
let result = await trackSelector.select(track: track, player: player)
|
|
@@ -81,41 +83,22 @@ public extension HybridAudioBrowser {
|
|
|
81
83
|
func notifyContentChanged(path: String) throws {
|
|
82
84
|
// Throws on a contextual path (matching Android's `require`) — the caller
|
|
83
85
|
// must pass the container path, or the invalidation silently hits nothing.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
externalContentChangedEmitter.emit(path)
|
|
88
|
-
|
|
89
|
-
// Re-resolve the path if it's the current browser path
|
|
90
|
-
if onMainActor({ browserManager.path }) == path {
|
|
91
|
-
navigate(path, clearError: false)
|
|
86
|
+
let isCurrentPath = try onMainActor {
|
|
87
|
+
try browserManager.invalidateContentCache(path)
|
|
88
|
+
return browserManager.path == path
|
|
92
89
|
}
|
|
90
|
+
externalContentChangedEmitter.emit(path)
|
|
91
|
+
if isCurrentPath { navigate(path, clearError: false) }
|
|
93
92
|
}
|
|
94
93
|
|
|
95
94
|
func invalidateAllContent() throws {
|
|
96
|
-
onMainActor {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
100
|
-
|
|
101
|
-
// Re-query the tabs source too: a callback source can resolve to new
|
|
102
|
-
// titles after an invalidation (e.g. a locale switch). BrowserManager
|
|
103
|
-
// emits tabsChanged only when the result actually differs, and a failed
|
|
104
|
-
// re-query keeps the current tabs. (Android needs no equivalent — its
|
|
105
|
-
// root re-query already runs queryTabs(); the web stub re-queries tabs on
|
|
106
|
-
// the re-navigation.)
|
|
107
|
-
Task {
|
|
108
|
-
_ = try? await browserManager.queryTabs()
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Re-resolve the JS-facing current path with the cache cleared.
|
|
112
|
-
Task { @MainActor in
|
|
113
|
-
do {
|
|
114
|
-
try await browserManager.load(browserManager.path, bypassCache: true)
|
|
115
|
-
} catch {
|
|
116
|
-
await reportNavigationError(error, path: browserManager.path)
|
|
95
|
+
onMainActor {
|
|
96
|
+
browserManager.invalidateAll { error, path in
|
|
97
|
+
await self.reportNavigationError(error, path: path)
|
|
117
98
|
}
|
|
118
99
|
}
|
|
100
|
+
// Tell external controllers (CarPlay) to refresh every displayed template.
|
|
101
|
+
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
119
102
|
}
|
|
120
103
|
|
|
121
104
|
func setFavorites(favorites: [String]) throws {
|
|
@@ -10,7 +10,6 @@ public extension HybridAudioBrowser {
|
|
|
10
10
|
|
|
11
11
|
func setupPlayer(options: NativeSetupPlayerOptions) throws -> Promise<Void> {
|
|
12
12
|
Promise.async {
|
|
13
|
-
// Create player and configure on main actor
|
|
14
13
|
await MainActor.run { [weak self] in
|
|
15
14
|
guard let self else { return }
|
|
16
15
|
// Set the category now, but activate only when playback produces output.
|
|
@@ -20,58 +19,34 @@ public extension HybridAudioBrowser {
|
|
|
20
19
|
// and un-published first. No-op on first setup.
|
|
21
20
|
destroyPlayer()
|
|
22
21
|
|
|
23
|
-
let newEngine = AVPlayerEngine(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
22
|
+
let newEngine = AVPlayerEngine(
|
|
23
|
+
callbacks: self,
|
|
24
|
+
resolver: browserManager.requests,
|
|
25
|
+
audioSession: audioSession,
|
|
26
|
+
networkStatus: networkMonitor,
|
|
27
|
+
bufferDuration: options.ios?.buffer ?? 0,
|
|
28
|
+
nowPlayingEnabled: options.autoUpdateNowPlayingMetadata ?? true,
|
|
29
|
+
nowPlayingFormatter: options.nowPlayingMetadataFormatter,
|
|
30
|
+
)
|
|
31
|
+
let newPlayer = Player(engine: newEngine, callbacks: self, retry: RetryLoop(networkMonitor: networkMonitor))
|
|
31
32
|
if let retry = options.retry { newPlayer.retry.updatePolicy(from: retry) }
|
|
32
33
|
newEngine.nowPlayingUpdater.source = newPlayer
|
|
33
34
|
engine = newEngine
|
|
34
35
|
player = newPlayer
|
|
35
36
|
audioSession.player = newPlayer
|
|
36
37
|
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
networkMonitor.onChanged = { [weak self] isOnline in
|
|
40
|
-
MainActor.assumeIsolated {
|
|
41
|
-
self?.onOnlineChanged(isOnline)
|
|
42
|
-
self?.engine?.nowPlayingUpdater.isOnline = isOnline
|
|
43
|
-
if isOnline { self?.player?.networkDidReturn() }
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// `options.keepSessionAliveOnError` is deliberately ignored here: iOS needs no masking.
|
|
48
|
-
// A terminal error keeps the now-playing info (never cleared on error), and next/previous
|
|
49
|
-
// stay enabled (gated on queue position, not state). So the
|
|
50
|
-
// session stays controllable through errors by default — the behavior Android achieves via
|
|
51
|
-
// InterceptingPlayer is already the iOS norm.
|
|
38
|
+
// `options.keepSessionAliveOnError` is ignored: on iOS an error never clears the now-playing
|
|
39
|
+
// info, and next/previous follow the queue, not the state.
|
|
52
40
|
|
|
53
41
|
// The bundled runtime options and initial state are part of the atomic launch
|
|
54
42
|
// description. Stage the state first so setup options and earlier pre-setup setter
|
|
55
43
|
// calls resolve by strict last-write-wins, then apply everything to the live player.
|
|
56
|
-
if let bundled = options.options {
|
|
57
|
-
updateOptions(options: bundled)
|
|
58
|
-
} else {
|
|
59
|
-
// updateOptions applies remote commands itself; without bundled options, apply the
|
|
60
|
-
// defaults (play, pause, next, previous, seekTo) here.
|
|
61
|
-
applyRemoteCommands()
|
|
62
|
-
}
|
|
44
|
+
if let bundled = options.options { updateOptions(options: bundled) }
|
|
63
45
|
if let mode = options.repeatMode { pendingRepeatMode = mode }
|
|
64
46
|
if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
|
|
65
47
|
applyPendingPlayerState()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
// the interval only when it *differs* from the stored one, but the store
|
|
69
|
-
// outlives the player — so on a re-setup with unchanged options the new
|
|
70
|
-
// player would come up with no progress timer at all.
|
|
71
|
-
player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
|
|
72
|
-
player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
|
|
73
|
-
|
|
74
|
-
// Notify listeners that player is ready (e.g., CarPlay)
|
|
48
|
+
// The stored options outlive the player, so a re-setup applies them to the new one.
|
|
49
|
+
applyOptions()
|
|
75
50
|
playerAndConfiguredBrowser.check()
|
|
76
51
|
}
|
|
77
52
|
}
|
|
@@ -95,40 +70,26 @@ public extension HybridAudioBrowser {
|
|
|
95
70
|
|
|
96
71
|
func updateOptions(options: NativeUpdateOptions) {
|
|
97
72
|
onMainActor {
|
|
98
|
-
let previousInterval = playerOptions.progressUpdateEventInterval
|
|
99
|
-
|
|
100
|
-
// Update stored options
|
|
101
73
|
playerOptions.update(from: options)
|
|
102
|
-
|
|
103
|
-
// Propagate the favorite flag to the browser so it can hydrate
|
|
104
|
-
// row hearts (the `favorite` capability is the single favoriting switch).
|
|
105
|
-
browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
|
|
106
|
-
|
|
107
|
-
// Apply remote commands to player
|
|
108
|
-
applyRemoteCommands()
|
|
109
|
-
|
|
110
|
-
// Apply progress update interval if changed
|
|
111
|
-
if playerOptions.progressUpdateEventInterval != previousInterval {
|
|
112
|
-
player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
|
|
113
|
-
}
|
|
114
|
-
|
|
74
|
+
applyOptions()
|
|
115
75
|
onOptionsChanged(playerOptions.toOptions())
|
|
116
76
|
playerOptionsChangedEmitter.emit(())
|
|
117
77
|
}
|
|
118
78
|
}
|
|
119
79
|
|
|
120
|
-
///
|
|
80
|
+
/// Applies the stored options to the browser, the player and its engine. Idempotent.
|
|
121
81
|
@MainActor
|
|
122
|
-
private func
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
82
|
+
private func applyOptions() {
|
|
83
|
+
// The `favorite` capability is the one favoriting switch: it also drives the row hearts.
|
|
84
|
+
browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
|
|
85
|
+
engine?.remoteCommandController.enable(
|
|
86
|
+
commands: playerOptions.capabilities.remoteCommands,
|
|
87
|
+
forwardJumpInterval: playerOptions.forwardJumpInterval,
|
|
88
|
+
backwardJumpInterval: playerOptions.backwardJumpInterval,
|
|
128
89
|
playbackRates: playerOptions.playbackRates,
|
|
129
90
|
)
|
|
130
|
-
|
|
131
|
-
|
|
91
|
+
player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
|
|
92
|
+
player?.setPlaybackIntervalEnabled(playerOptions.playbackIntervalEnabled)
|
|
132
93
|
}
|
|
133
94
|
|
|
134
95
|
func getOptions() throws -> Options {
|
|
@@ -137,7 +98,7 @@ public extension HybridAudioBrowser {
|
|
|
137
98
|
|
|
138
99
|
func setPlaybackIntervalEnabled(enabled: Bool) {
|
|
139
100
|
onMainActor {
|
|
140
|
-
playbackIntervalEnabled = enabled
|
|
101
|
+
playerOptions.playbackIntervalEnabled = enabled
|
|
141
102
|
player?.setPlaybackIntervalEnabled(enabled)
|
|
142
103
|
}
|
|
143
104
|
}
|
|
@@ -230,7 +230,9 @@ public extension HybridAudioBrowser {
|
|
|
230
230
|
try player.replace(player.queue.currentIndex, updated)
|
|
231
231
|
// The only emit: a favorite toggle mutates the active track in place, so
|
|
232
232
|
// onPlaybackActiveTrackChanged stays transition-only.
|
|
233
|
-
|
|
233
|
+
let event = FavoriteChangedEvent(track: updated, favorited: favorited)
|
|
234
|
+
onFavoriteChanged(event)
|
|
235
|
+
favoriteChangedEmitter.emit(event)
|
|
234
236
|
return true
|
|
235
237
|
}
|
|
236
238
|
}
|
|
@@ -334,64 +336,6 @@ public extension HybridAudioBrowser {
|
|
|
334
336
|
}
|
|
335
337
|
|
|
336
338
|
func openOutputPicker() throws {
|
|
337
|
-
DispatchQueue.main.async {
|
|
338
|
-
guard let self else { return }
|
|
339
|
-
guard let windowScene = UIApplication.shared.activeWindowScene,
|
|
340
|
-
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
|
341
|
-
var topController = window.rootViewController
|
|
342
|
-
else {
|
|
343
|
-
self.logger.error("openOutputPicker: no active window scene / root view controller")
|
|
344
|
-
return
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// Walk up to the topmost presented view controller
|
|
348
|
-
while let presented = topController.presentedViewController {
|
|
349
|
-
topController = presented
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// AVRoutePickerView has no public API to present its picker
|
|
353
|
-
// programmatically, so we add it to the hierarchy and tap its internal
|
|
354
|
-
// button. Two requirements that are easy to get wrong:
|
|
355
|
-
// 1. The view must be *visible* to the system (a `isHidden` source view
|
|
356
|
-
// suppresses the presentation), so we keep it transparent instead.
|
|
357
|
-
// 2. The internal button can be nested below the top-level subviews on
|
|
358
|
-
// modern iOS, so we search recursively rather than only direct children.
|
|
359
|
-
let routePicker = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
360
|
-
routePicker.alpha = 0.01
|
|
361
|
-
topController.view.addSubview(routePicker)
|
|
362
|
-
|
|
363
|
-
let button = Self.firstButton(in: routePicker)
|
|
364
|
-
self.logger.notice(
|
|
365
|
-
"openOutputPicker: hierarchy=\(Self.describeHierarchy(routePicker), privacy: .public), buttonFound=\(button != nil, privacy: .public)",
|
|
366
|
-
)
|
|
367
|
-
|
|
368
|
-
if let button {
|
|
369
|
-
button.sendActions(for: .touchUpInside)
|
|
370
|
-
} else {
|
|
371
|
-
self.logger.error("openOutputPicker: no UIButton found inside AVRoutePickerView")
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
// Keep the source view in the hierarchy long enough for the picker to
|
|
375
|
-
// present (on iPad it anchors a popover to this view).
|
|
376
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
377
|
-
routePicker.removeFromSuperview()
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
/// Depth-first search for the first `UIButton` inside a view tree.
|
|
383
|
-
private static func firstButton(in view: UIView) -> UIButton? {
|
|
384
|
-
if let button = view as? UIButton { return button }
|
|
385
|
-
for subview in view.subviews {
|
|
386
|
-
if let button = firstButton(in: subview) { return button }
|
|
387
|
-
}
|
|
388
|
-
return nil
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/// Compact description of a view tree, e.g. `AVRoutePickerView[UIButton[UIImageView]]`.
|
|
392
|
-
private static func describeHierarchy(_ view: UIView) -> String {
|
|
393
|
-
let name = String(describing: type(of: view))
|
|
394
|
-
let children = view.subviews.map(describeHierarchy).joined(separator: ", ")
|
|
395
|
-
return children.isEmpty ? name : "\(name)[\(children)]"
|
|
339
|
+
DispatchQueue.main.async { OutputPicker.present() }
|
|
396
340
|
}
|
|
397
341
|
}
|
|
@@ -10,8 +10,6 @@ import os.log
|
|
|
10
10
|
extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
11
11
|
public func playerDidChangePlayback(_ playback: Playback) {
|
|
12
12
|
onPlaybackChanged(playback)
|
|
13
|
-
// The Player sets the error before this fires, so the error line renders.
|
|
14
|
-
engine?.nowPlayingUpdater.render()
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
public func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent) {
|
|
@@ -25,8 +23,8 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
25
23
|
index: event.index,
|
|
26
24
|
track: event.track.map(browserManager.hydrateFavorite),
|
|
27
25
|
)
|
|
26
|
+
onPlaybackActiveTrackChanged(hydrated)
|
|
28
27
|
activeTrackChangedEmitter.emit(hydrated)
|
|
29
|
-
engine?.nowPlayingUpdater.trackChanged()
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
public func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent) {
|
|
@@ -38,32 +36,11 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
public func playerDidChangePlayWhenReady(_ playWhenReady: Bool) {
|
|
41
|
-
// Intent to play again is the earliest cancel signal for a pending session release.
|
|
42
|
-
if playWhenReady { audioSession.cancelRelease() }
|
|
43
39
|
onPlaybackPlayWhenReadyChanged(PlaybackPlayWhenReadyChangedEvent(playWhenReady: playWhenReady))
|
|
44
40
|
}
|
|
45
41
|
|
|
46
42
|
public func playerDidChangePlayingState(_ state: PlayingState) {
|
|
47
43
|
onPlaybackPlayingState(state)
|
|
48
|
-
// Lazily activate the audio session the moment playback actually produces — or is buffering to
|
|
49
|
-
// produce — output. This (not setup) is where we grab the session, so launching the app never
|
|
50
|
-
// interrupts other apps' audio; the interruption only happens when the user truly starts
|
|
51
|
-
// playback, which is correct. Keyed off play *state*, not `playWhenReady`, because the intent
|
|
52
|
-
// can be set at setup (before any track) while output only begins here. Idempotent:
|
|
53
|
-
// `setActive(true)` is a no-op when the session is already active.
|
|
54
|
-
if state.playing || state.buffering {
|
|
55
|
-
// The now-playing session's activation request at link time predates this
|
|
56
|
-
// (the audio session was still inactive, and the system may have declined);
|
|
57
|
-
// re-request once the audio session is active, so the surface can appear even if
|
|
58
|
-
// playback never produces audio (first-ever load failing on a dead stream).
|
|
59
|
-
audioSession.activate { [weak self] in self?.engine?.reactivateNowPlayingSession() }
|
|
60
|
-
}
|
|
61
|
-
// A stall starting or recovering may not change the Player state.
|
|
62
|
-
engine?.nowPlayingUpdater.render()
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
public func playerShouldReleaseSession() {
|
|
66
|
-
audioSession.scheduleRelease()
|
|
67
44
|
}
|
|
68
45
|
|
|
69
46
|
public func playerDidEndQueue(_ event: PlaybackQueueEndedEvent) {
|
|
@@ -71,16 +48,18 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
71
48
|
}
|
|
72
49
|
|
|
73
50
|
public func playerDidChangeQueue(_ tracks: [Track]) {
|
|
51
|
+
onPlaybackQueueChanged(tracks)
|
|
74
52
|
queueChangedEmitter.emit(tracks)
|
|
75
53
|
}
|
|
76
54
|
|
|
77
55
|
public func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent) {
|
|
56
|
+
onPlaybackRepeatModeChanged(event)
|
|
78
57
|
repeatModeChangedEmitter.emit(event)
|
|
79
58
|
}
|
|
80
59
|
|
|
81
60
|
public func playerDidChangeShuffleEnabled(_ enabled: Bool) {
|
|
82
|
-
shuffleChangedEmitter.emit(enabled)
|
|
83
61
|
onPlaybackShuffleModeChanged(enabled)
|
|
62
|
+
shuffleChangedEmitter.emit(enabled)
|
|
84
63
|
}
|
|
85
64
|
|
|
86
65
|
public func playerDidError(_ event: PlaybackErrorEvent) {
|
|
@@ -100,13 +79,8 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
100
79
|
onTrackMetadata(metadata)
|
|
101
80
|
}
|
|
102
81
|
|
|
103
|
-
public func playerDidReceiveTimedMetadata(_
|
|
104
|
-
|
|
105
|
-
if let metadata = TimedMetadata.from(items: group.items, icyCharset: player?.currentTrack?.icyCharset) {
|
|
106
|
-
onTimedMetadata(metadata)
|
|
107
|
-
engine?.nowPlayingUpdater.receivedTimedMetadata(metadata)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
82
|
+
public func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata) {
|
|
83
|
+
onTimedMetadata(metadata)
|
|
110
84
|
}
|
|
111
85
|
|
|
112
86
|
public func playerDidReceiveChapterMetadata(_ groups: [AVTimedMetadataGroup]) {
|
|
@@ -126,10 +100,6 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
126
100
|
dispatchRemote(handler: handleRemoteStop, report: onRemoteStop) { player?.stop() }
|
|
127
101
|
}
|
|
128
102
|
|
|
129
|
-
public func remotePlayPause() {
|
|
130
|
-
if player?.playWhenReady == true { remotePause() } else { remotePlay() }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
103
|
public func remoteNext() {
|
|
134
104
|
dispatchRemote(handler: handleRemoteNext, report: onRemoteNext) { player?.next() }
|
|
135
105
|
}
|
|
@@ -174,12 +144,10 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
174
144
|
}
|
|
175
145
|
|
|
176
146
|
public func remoteChangeRepeatMode(mode: RepeatMode) {
|
|
177
|
-
// Apply the repeat mode change from CarPlay/lock screen
|
|
178
147
|
try? setRepeatMode(mode: mode)
|
|
179
148
|
}
|
|
180
149
|
|
|
181
150
|
public func remoteChangeShuffleMode(enabled: Bool) {
|
|
182
|
-
// Apply the shuffle mode change from CarPlay/lock screen
|
|
183
151
|
try? setShuffleEnabled(enabled: enabled)
|
|
184
152
|
}
|
|
185
153
|
|
|
@@ -8,7 +8,7 @@ import os.log
|
|
|
8
8
|
@MainActor let playerAndConfiguredBrowser = ReadyValue<(HybridAudioBrowser, Player)> {
|
|
9
9
|
guard let browser = HybridAudioBrowser.shared,
|
|
10
10
|
browser.browserManager.isConfigured,
|
|
11
|
-
let player = browser.
|
|
11
|
+
let player = browser.player else { return nil }
|
|
12
12
|
return (browser, player)
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -34,14 +34,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
34
34
|
/// silent.
|
|
35
35
|
static let instanceChangedEmitter = Emitter<Void>()
|
|
36
36
|
|
|
37
|
-
///
|
|
38
|
-
///
|
|
39
|
-
///
|
|
40
|
-
///
|
|
41
|
-
/// stale. `init` of the replacement instance drops them too, but only after
|
|
42
|
-
/// the new runtime is up; this observer closes the window in between.
|
|
43
|
-
/// Observed by name so the library needs no React-Core dependency (and
|
|
44
|
-
/// stays inert under SPM tests, where nothing posts it).
|
|
37
|
+
/// React Native posts this notification when a JS reload is triggered, before the old runtime
|
|
38
|
+
/// is destroyed: the earliest sign this instance's JS callbacks go stale. The replacement's
|
|
39
|
+
/// `init` drops them too, but only once the new runtime is up. Observed by name, so the library
|
|
40
|
+
/// needs no React-Core dependency.
|
|
45
41
|
@MainActor private static let reloadObserver: NSObjectProtocol = NotificationCenter.default.addObserver(
|
|
46
42
|
forName: Notification.Name("RCTTriggerReloadCommandNotification"),
|
|
47
43
|
object: nil,
|
|
@@ -71,29 +67,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
71
67
|
@MainActor var pendingRepeatMode: RepeatMode?
|
|
72
68
|
@MainActor let playerOptions = PlayerUpdateOptions()
|
|
73
69
|
|
|
74
|
-
/// Configured playback rates for the playback-rate capability (for CarPlay rate cycling)
|
|
75
|
-
@MainActor var playbackRates: [Double] { playerOptions.playbackRates }
|
|
76
|
-
@MainActor var carPlayUpNextButton: Bool { playerOptions.carPlayUpNextButton }
|
|
77
|
-
@MainActor var carPlayNowPlayingButtons: [CarPlayNowPlayingButton] {
|
|
78
|
-
playerOptions.carPlayNowPlayingButtons
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
@MainActor var carPlayPlayingIndicatorLocation: CarPlayPlayingIndicatorLocation {
|
|
82
|
-
playerOptions.carPlayPlayingIndicatorLocation
|
|
83
|
-
}
|
|
84
|
-
|
|
85
70
|
@MainActor lazy var navigationError = NavigationErrorState(
|
|
86
|
-
onErrorChanged: { [weak self] in self?.
|
|
71
|
+
onErrorChanged: { [weak self] in self?.onNavigationError(NavigationErrorEvent(error: $0)) },
|
|
87
72
|
onFormattedChanged: { [weak self] in self?.onFormattedNavigationError($0) },
|
|
88
73
|
)
|
|
89
74
|
|
|
90
|
-
// MARK: -
|
|
75
|
+
// MARK: - Emitters for Native Listeners
|
|
91
76
|
|
|
92
|
-
/// Emitters allow multiple listeners for each event type, avoiding callback hijacking
|
|
93
77
|
public let favoriteChangedEmitter = Emitter<FavoriteChangedEvent>()
|
|
94
78
|
public let activeTrackChangedEmitter = Emitter<PlaybackActiveTrackChangedEvent>()
|
|
95
79
|
public let queueChangedEmitter = Emitter<[Track]>()
|
|
96
|
-
public let navigationErrorEmitter = Emitter<NavigationErrorEvent>()
|
|
97
80
|
public let repeatModeChangedEmitter = Emitter<RepeatModeChangedEvent>()
|
|
98
81
|
public let shuffleChangedEmitter = Emitter<Bool>()
|
|
99
82
|
public let externalContentChangedEmitter = Emitter<String>()
|
|
@@ -111,17 +94,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
111
94
|
|
|
112
95
|
/// Runs a closure on MainActor synchronously. Safe for non-Sendable return types
|
|
113
96
|
/// because DispatchQueue.main.sync blocks the caller — no concurrent access window.
|
|
114
|
-
func onMainActor<T>(_ work: @MainActor () -> T) -> T {
|
|
115
|
-
if Thread.isMainThread {
|
|
116
|
-
MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
|
|
117
|
-
} else {
|
|
118
|
-
DispatchQueue.main.sync {
|
|
119
|
-
MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
func onMainActor<T>(_ work: @MainActor () throws -> T) throws -> T {
|
|
97
|
+
func onMainActor<T>(_ work: @MainActor () throws -> T) rethrows -> T {
|
|
125
98
|
if Thread.isMainThread {
|
|
126
99
|
try MainActor.assumeIsolated { try UncheckedSendableBox(value: work()) }.value
|
|
127
100
|
} else {
|
|
@@ -159,14 +132,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
159
132
|
let configuredPath = configuration.path
|
|
160
133
|
onMainActor { browserManager.config = BrowserConfig(from: configuration) }
|
|
161
134
|
|
|
162
|
-
// Query tabs and navigate to initial path after config is set (matches Kotlin behavior)
|
|
163
135
|
Task { @MainActor in
|
|
164
|
-
// Notify waiting listeners (e.g., CarPlay cold start)
|
|
165
136
|
playerAndConfiguredBrowser.check()
|
|
166
137
|
}
|
|
167
138
|
Task {
|
|
168
139
|
let tabs = try? await browserManager.queryTabs()
|
|
169
|
-
// Navigate to configured path, first tab, or "/"
|
|
170
140
|
navigate(configuredPath ?? tabs?.first?.path ?? "/", clearError: true)
|
|
171
141
|
}
|
|
172
142
|
}
|
|
@@ -258,9 +228,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
258
228
|
public var onNowPlayingChanged: (NowPlayingMetadata) -> Void = { _ in }
|
|
259
229
|
public var onOnlineChanged: (Bool) -> Void = { _ in } {
|
|
260
230
|
didSet {
|
|
261
|
-
//
|
|
262
|
-
// subscription and can also drive the now-playing re-render + player reconnect). Here we only
|
|
263
|
-
// push the current state to a newly-attached subscriber.
|
|
231
|
+
// A new subscriber hears the current state at once.
|
|
264
232
|
onOnlineChanged(networkMonitor.isOnline)
|
|
265
233
|
}
|
|
266
234
|
}
|
|
@@ -280,11 +248,6 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
280
248
|
}
|
|
281
249
|
}
|
|
282
250
|
|
|
283
|
-
/// Stored so a re-setup can re-apply it: unlike the other runtime options this
|
|
284
|
-
/// one has no home in `playerOptions`, and the player it was pushed to does not
|
|
285
|
-
/// survive `setupPlayer`.
|
|
286
|
-
@MainActor var playbackIntervalEnabled = false
|
|
287
|
-
|
|
288
251
|
// MARK: - Initialization
|
|
289
252
|
|
|
290
253
|
override public init() {
|
|
@@ -302,6 +265,15 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
302
265
|
HybridAudioBrowser.shared = self
|
|
303
266
|
audioSession.outputChanged.addListener { [weak self] in self?.onOutputChanged($0) }
|
|
304
267
|
audioSession.volumeChanged.addListener { [weak self] in self?.onSystemVolumeChanged($0) }
|
|
268
|
+
// From the first setup on, whether or not JS subscribes: JS, then the player.
|
|
269
|
+
networkMonitor.changed.addListener { [weak self] isOnline in
|
|
270
|
+
guard let self else { return }
|
|
271
|
+
MainActor.assumeIsolated {
|
|
272
|
+
guard let player = self.player else { return }
|
|
273
|
+
self.onOnlineChanged(isOnline)
|
|
274
|
+
player.networkStatusDidChange(isOnline: isOnline)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
305
277
|
browseGate.resolve = { [weak self] request in
|
|
306
278
|
guard let self else { throw CancellationError() }
|
|
307
279
|
return try await resolveGate(request).await().await()
|
|
@@ -312,24 +284,18 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
312
284
|
// controller is subscribed to a dead instance's emitters.
|
|
313
285
|
HybridAudioBrowser.instanceChangedEmitter.emit(())
|
|
314
286
|
}
|
|
315
|
-
|
|
287
|
+
forwardBrowserEventsToJS()
|
|
316
288
|
}
|
|
317
289
|
|
|
318
290
|
/// Drops every JS-provided callback that native code can invoke on its own
|
|
319
291
|
/// initiative (browse resolvers, `handleTrackLoad`, `resolveAlbumPath`,
|
|
320
292
|
/// `formatNavigationError`, `resolveGate`, the now-playing formatter).
|
|
321
293
|
///
|
|
322
|
-
/// Called on the
|
|
323
|
-
///
|
|
324
|
-
///
|
|
325
|
-
///
|
|
326
|
-
///
|
|
327
|
-
/// working off the data-only config with built-in fallbacks; `resolveGate`
|
|
328
|
-
/// reverts to the fail-closed default it documents for exactly this window.
|
|
329
|
-
/// One-shot: on a normal reload both triggers hit the same dying instance
|
|
330
|
-
/// (the reload notification, then the replacement's `init`), and the second
|
|
331
|
-
/// strip would re-fire the config-setter cascade — another `configChanged`
|
|
332
|
-
/// → CarPlay now-playing button rebuild — for nothing.
|
|
294
|
+
/// Called on the previous instance when a new one takes over: invoking a callback of a dead
|
|
295
|
+
/// runtime aborts the process (Nitro throws across a `noexcept` boundary). Native code still
|
|
296
|
+
/// bound to this instance (CarPlay mid-reload) keeps working off the data-only config;
|
|
297
|
+
/// `resolveGate` reverts to its fail-closed default. One-shot: both triggers reach the same
|
|
298
|
+
/// dying instance, and a second strip would re-run the config setter's cascade.
|
|
333
299
|
@MainActor private var didDropJSCallbacks = false
|
|
334
300
|
|
|
335
301
|
@MainActor
|
|
@@ -348,61 +314,32 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
348
314
|
}
|
|
349
315
|
|
|
350
316
|
deinit {
|
|
351
|
-
// Safety net:
|
|
317
|
+
// Safety net: the AVPlayer must not outlive this instance. `self` can't reach the main
|
|
318
|
+
// actor from here, so the teardown takes what it releases.
|
|
352
319
|
let player = self.player, engine = self.engine
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
player?.clear()
|
|
356
|
-
engine?.destroy()
|
|
357
|
-
}
|
|
358
|
-
} else {
|
|
359
|
-
DispatchQueue.main.async {
|
|
360
|
-
MainActor.assumeIsolated {
|
|
361
|
-
player?.clear()
|
|
362
|
-
engine?.destroy()
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/// Returns the player, if setup has been called.
|
|
369
|
-
func getPlayer() -> Player? {
|
|
370
|
-
player
|
|
320
|
+
let tearDown: @Sendable () -> Void = { MainActor.assumeIsolated { Self.destroy(player, engine) } }
|
|
321
|
+
if Thread.isMainThread { tearDown() } else { DispatchQueue.main.async(execute: tearDown) }
|
|
371
322
|
}
|
|
372
323
|
|
|
373
324
|
/// Stops the player and releases its media stack: the AVPlayer and the now-playing session
|
|
374
325
|
/// outlive a dropped reference otherwise.
|
|
375
326
|
@MainActor
|
|
376
327
|
func destroyPlayer() {
|
|
328
|
+
Self.destroy(player, engine)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@MainActor
|
|
332
|
+
private static func destroy(_ player: Player?, _ engine: AVPlayerEngine?) {
|
|
377
333
|
player?.clear()
|
|
378
334
|
engine?.destroy()
|
|
379
335
|
}
|
|
380
336
|
|
|
381
|
-
///
|
|
382
|
-
|
|
337
|
+
/// Forwards the browser's events to JS. The bridge's own events call JS where they are
|
|
338
|
+
/// emitted; their emitters are for native listeners (CarPlay).
|
|
339
|
+
private func forwardBrowserEventsToJS() {
|
|
383
340
|
browserManager.pathChanged.addListener { [weak self] in self?.onPathChanged($0) }
|
|
384
341
|
browserManager.tabsChanged.addListener { [weak self] in self?.onTabsChanged($0) }
|
|
385
342
|
browserManager.contentChanged.addListener { [weak self] in self?.onContentChanged($0) }
|
|
386
|
-
|
|
387
|
-
favoriteChangedEmitter.addListener { [weak self] event in
|
|
388
|
-
self?.onFavoriteChanged(event)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
activeTrackChangedEmitter.addListener { [weak self] event in
|
|
392
|
-
self?.onPlaybackActiveTrackChanged(event)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
queueChangedEmitter.addListener { [weak self] tracks in
|
|
396
|
-
self?.onPlaybackQueueChanged(tracks)
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
navigationErrorEmitter.addListener { [weak self] event in
|
|
400
|
-
self?.onNavigationError(event)
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
repeatModeChangedEmitter.addListener { [weak self] event in
|
|
404
|
-
self?.onPlaybackRepeatModeChanged(event)
|
|
405
|
-
}
|
|
406
343
|
}
|
|
407
344
|
}
|
|
408
345
|
|