react-native-audio-browser 0.2.0-next.28 → 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.
Files changed (137) hide show
  1. package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
  2. package/android/src/test/java/com/audiobrowser/browser/BrowserUrlResolutionTest.kt +2 -2
  3. package/ios/AVPlayerEngine.swift +334 -0
  4. package/ios/AVPlayerEngineCallbacks.swift +15 -0
  5. package/ios/Browser/BrowseGate.swift +58 -0
  6. package/ios/Browser/BrowserConfig.swift +31 -89
  7. package/ios/Browser/BrowserManager.swift +68 -487
  8. package/ios/Browser/NavigationErrorState.swift +41 -0
  9. package/ios/Browser/PageResolver.swift +227 -0
  10. package/ios/Browser/RequestLayer.swift +90 -0
  11. package/ios/Browser/{BrowserManager+URLResolution.swift → RequestPipeline.swift} +175 -107
  12. package/ios/CarPlay/CPInterfaceController+Safe.swift +11 -24
  13. package/ios/CarPlay/CarPlayArtworkResolver.swift +35 -83
  14. package/ios/CarPlay/CarPlayController.swift +42 -1442
  15. package/ios/CarPlay/CarPlayImageLoader.swift +42 -83
  16. package/ios/CarPlay/CarPlayListItemFactory.swift +9 -23
  17. package/ios/CarPlay/CarPlayNowPlayingManager.swift +119 -129
  18. package/ios/CarPlay/CarPlaySession+Navigator.swift +114 -0
  19. package/ios/CarPlay/CarPlaySession+PageLoader.swift +92 -0
  20. package/ios/CarPlay/CarPlaySession+Refresher.swift +43 -0
  21. package/ios/CarPlay/CarPlaySession+TabBar.swift +193 -0
  22. package/ios/CarPlay/CarPlaySession.swift +209 -0
  23. package/ios/CarPlay/CarPlayTemplates.swift +63 -0
  24. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +20 -0
  25. package/ios/CarPlay/MediaIntentPlayback.swift +74 -0
  26. package/ios/CarPlay/RNABMediaAddHandler.swift +2 -18
  27. package/ios/CarPlay/RNABMediaAffinityHandler.swift +5 -21
  28. package/ios/CarPlay/RNABMediaIntentHandler.swift +2 -3
  29. package/ios/Extension/NavigationError+Conversion.swift +7 -4
  30. package/ios/Extension/UIImage+Adaptive.swift +12 -0
  31. package/ios/Extension/URLError+Connectivity.swift +15 -0
  32. package/ios/Http/HttpClient.swift +63 -145
  33. package/ios/HybridAudioBrowser+Browser.swift +153 -0
  34. package/ios/HybridAudioBrowser+Options.swift +144 -0
  35. package/ios/HybridAudioBrowser+Player.swift +397 -0
  36. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +190 -0
  37. package/ios/HybridAudioBrowser+Stubs.swift +46 -0
  38. package/ios/HybridAudioBrowser.swift +74 -1687
  39. package/ios/Model/NitroTypeStubs.swift +18 -1
  40. package/ios/Model/PlayerUpdateOptions.swift +1 -1
  41. package/ios/NowPlayingInfo/NowPlayingInfoController.swift +18 -53
  42. package/ios/Observer/ItemObserver.swift +90 -0
  43. package/ios/Observer/PlayerObserver.swift +59 -0
  44. package/ios/Option/SessionCategories.swift +41 -92
  45. package/ios/Player/AudioSessionController.swift +177 -0
  46. package/ios/Player/DeferredSeek.swift +70 -0
  47. package/ios/Player/ErrorStatus.swift +27 -0
  48. package/ios/Player/MediaLoader.swift +24 -11
  49. package/ios/Player/NowPlayingUpdater.swift +157 -58
  50. package/ios/Player/PlaybackFailure.swift +125 -0
  51. package/ios/Player/PlaybackPersistence.swift +73 -0
  52. package/ios/Player/Player.swift +1083 -0
  53. package/ios/Player/{PlaybackCoordinatorCallbacks.swift → PlayerCallbacks.swift} +3 -4
  54. package/ios/Player/PlayerEngine.swift +89 -0
  55. package/ios/Player/PlayerStatusTypes.swift +2 -6
  56. package/ios/Player/QueueManager.swift +52 -59
  57. package/ios/Player/RetryLoop.swift +261 -0
  58. package/ios/Player/SleepTimerManager.swift +1 -1
  59. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +2 -3
  60. package/ios/RemoteCommand/RemoteCommandController.swift +85 -367
  61. package/ios/Tests/CarPlayArtworkResolverTests.swift +60 -356
  62. package/ios/Tests/DeferredSeekTests.swift +70 -0
  63. package/ios/Tests/EmitterTests.swift +29 -0
  64. package/ios/Tests/GenerationMemoTests.swift +121 -0
  65. package/ios/Tests/LRUCacheTests.swift +4 -5
  66. package/ios/Tests/MediaLoaderTests.swift +12 -33
  67. package/ios/Tests/ObserverTests.swift +88 -0
  68. package/ios/Tests/PlaybackErrorTests.swift +76 -0
  69. package/ios/Tests/PlaybackFailureTests.swift +189 -0
  70. package/ios/Tests/PlaybackPersistenceTests.swift +93 -0
  71. package/ios/Tests/PlayerPolicyTests.swift +405 -0
  72. package/ios/Tests/{PlaybackCoordinatorTests.swift → PlayerTests.swift} +298 -248
  73. package/ios/Tests/PlayingStateTests.swift +42 -0
  74. package/ios/Tests/QueueManagerTests.swift +76 -107
  75. package/ios/Tests/ReadyValueTests.swift +73 -0
  76. package/ios/Tests/RemoteCommandControllerTests.swift +10 -30
  77. package/ios/Tests/RemoteCommandTests.swift +2 -4
  78. package/ios/Tests/RequestLayerTests.swift +112 -0
  79. package/ios/Tests/RetryLoopTests.swift +270 -0
  80. package/ios/Tests/Support/TestTypes.swift +111 -112
  81. package/ios/Tests/TrackIdTemplateTests.swift +1 -1
  82. package/ios/Tests/URLErrorConnectivityTests.swift +20 -0
  83. package/ios/TrackSelector.swift +1 -1
  84. package/ios/Util/ArtworkImageFetcher.swift +42 -0
  85. package/ios/Util/Emitter.swift +42 -0
  86. package/ios/Util/GenerationMemo.swift +36 -0
  87. package/ios/Util/LRUCache.swift +23 -162
  88. package/ios/Util/NetworkMonitor.swift +1 -1
  89. package/ios/Util/ReadyValue.swift +56 -0
  90. package/ios/Util/SFSymbolRenderer.swift +35 -29
  91. package/ios/Util/SVGProcessor.swift +0 -10
  92. package/lib/commonjs/web/NativeAudioBrowser.js +1 -1
  93. package/lib/commonjs/web/TrackPlayer/PlayingStateFactory.js +1 -1
  94. package/lib/module/web/NativeAudioBrowser.js +1 -1
  95. package/lib/module/web/TrackPlayer/PlayingStateFactory.js +1 -1
  96. package/lib/typescript/src/types/browser.d.ts +3 -3
  97. package/package.json +1 -1
  98. package/src/types/browser.ts +3 -3
  99. package/src/web/NativeAudioBrowser.ts +1 -1
  100. package/src/web/TrackPlayer/PlayingStateFactory.test.ts +1 -1
  101. package/src/web/TrackPlayer/PlayingStateFactory.ts +1 -1
  102. package/ios/Bridge.h +0 -8
  103. package/ios/Browser/MediaResolveComposer.swift +0 -114
  104. package/ios/NowPlayingInfo/MediaItemProperty.swift +0 -78
  105. package/ios/NowPlayingInfo/NowPlayingInfoKeyValue.swift +0 -6
  106. package/ios/NowPlayingInfo/NowPlayingInfoProperty.swift +0 -220
  107. package/ios/Observer/PlayerItemNotificationObserver.swift +0 -119
  108. package/ios/Observer/PlayerItemPropertyObserver.swift +0 -133
  109. package/ios/Observer/PlayerStateObserver.swift +0 -50
  110. package/ios/Observer/PlayerTimeObserver.swift +0 -55
  111. package/ios/Option/PitchAlgorithms.swift +0 -17
  112. package/ios/Option/TimeEventFrequency.swift +0 -18
  113. package/ios/Player/LoadSeekCoordinator.swift +0 -99
  114. package/ios/Player/MediaLoaderDelegate.swift +0 -11
  115. package/ios/Player/PlaybackCoordinator.swift +0 -761
  116. package/ios/Player/PlaybackEffectHandler.swift +0 -54
  117. package/ios/Player/PlaybackErrorHandler.swift +0 -175
  118. package/ios/Player/PlaybackStateStore.swift +0 -27
  119. package/ios/Player/PlayingStateManager.swift +0 -33
  120. package/ios/Player/RetryManager.swift +0 -384
  121. package/ios/Player/SeekCompletionHandler.swift +0 -5
  122. package/ios/Player/SleepTimerHandling.swift +0 -28
  123. package/ios/Support/RNTrackPlayer-Bridging-Header.h +0 -7
  124. package/ios/Tests/LoadSeekCoordinatorTests.swift +0 -327
  125. package/ios/Tests/MediaResolveTests.swift +0 -126
  126. package/ios/Tests/OnceValueTests.swift +0 -79
  127. package/ios/Tests/PlaybackErrorHandlerTests.swift +0 -549
  128. package/ios/Tests/PlaybackStateStoreTests.swift +0 -49
  129. package/ios/Tests/PlayerTimeObserverTests.swift +0 -37
  130. package/ios/Tests/PlayingStateManagerTests.swift +0 -57
  131. package/ios/Tests/RetryManagerTests.swift +0 -178
  132. package/ios/TrackPlayer.swift +0 -1042
  133. package/ios/TrackPlayerCallbacks.swift +0 -53
  134. package/ios/Util/MetadataAdapter.swift +0 -131
  135. package/ios/Util/OnceValue.swift +0 -104
  136. /package/ios/{Player → CarPlay}/MediaIntentCriteria.swift +0 -0
  137. /package/ios/{Browser → CarPlay}/SectionPresentation.swift +0 -0
@@ -0,0 +1,153 @@
1
+ import AVFoundation
2
+ import AVKit
3
+ import Foundation
4
+ import MediaPlayer
5
+ import NitroModules
6
+ import os.log
7
+
8
+ public extension HybridAudioBrowser {
9
+ /// Navigates the in-app browser, reporting a failure as the navigation error. A clear is
10
+ /// synchronous: JS hears it before the navigation that may set the error again.
11
+ internal func navigate(_ path: String, clearError: Bool) {
12
+ if clearError { onMainActor { navigationError.clear() } }
13
+ Task { @MainActor in
14
+ do {
15
+ try await browserManager.load(path)
16
+ } catch {
17
+ await reportNavigationError(error, path: path)
18
+ }
19
+ }
20
+ }
21
+
22
+ @MainActor
23
+ private func reportNavigationError(_ error: Error, path: String) async {
24
+ await navigationError.report(NavigationError.from(error)) { [browserManager] in
25
+ await browserManager.formatted($0, path: path)
26
+ }
27
+ }
28
+
29
+ // MARK: - Browser Methods
30
+
31
+ func navigatePath(path: String) throws {
32
+ navigate(path, clearError: true)
33
+ }
34
+
35
+ func navigateTrack(track: Track) throws {
36
+ // @MainActor: `player` is main-confined, and `trackSelector.select` is
37
+ // MainActor anyway — a bare Task read the reference off the cooperative
38
+ // pool, racing the assignment in `setupPlayer`.
39
+ Task { @MainActor in
40
+ guard let player else { return }
41
+ let result = await trackSelector.select(track: track, player: player)
42
+ switch result {
43
+ case let .play(intent):
44
+ player.execute(intent)
45
+ case .intercepted:
46
+ break
47
+ case let .browse(path):
48
+ navigate(path, clearError: true)
49
+ case .none:
50
+ break
51
+ }
52
+ }
53
+ }
54
+
55
+ func search(query: String) throws -> Promise<[Track]> {
56
+ Promise.async { [weak self] in
57
+ guard let self else { return [] }
58
+ let resolved = try await browserManager.search(query)
59
+ return resolved.flattenedChildren ?? []
60
+ }
61
+ }
62
+
63
+ func getContent() throws -> ResolvedTrack? {
64
+ onMainActor { browserManager.content }
65
+ }
66
+
67
+ func getNavigationError() throws -> NavigationError? {
68
+ onMainActor { navigationError.error }
69
+ }
70
+
71
+ func getFormattedNavigationError() throws -> FormattedNavigationError? {
72
+ onMainActor { navigationError.formatted }
73
+ }
74
+
75
+ /// Internal signal sent on `externalContentChangedEmitter` to tell CarPlay to
76
+ /// refresh every displayed template. Not a real path (paths are always
77
+ /// `/…`), so it can't collide with one; kept internal — the public API is
78
+ /// `invalidateAllContent()`.
79
+ internal static let invalidateAllSentinel = "__rnab_invalidate_all__"
80
+
81
+ func notifyContentChanged(path: String) throws {
82
+ // Throws on a contextual path (matching Android's `require`) — the caller
83
+ // must pass the container path, or the invalidation silently hits nothing.
84
+ try onMainActor { try browserManager.invalidateContentCache(path) }
85
+
86
+ // Notify external controllers (CarPlay) that content changed
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)
92
+ }
93
+ }
94
+
95
+ func invalidateAllContent() throws {
96
+ onMainActor { browserManager.clearContentCache() }
97
+
98
+ // Tell external controllers (CarPlay) to refresh every displayed template.
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)
117
+ }
118
+ }
119
+ }
120
+
121
+ func setFavorites(favorites: [String]) throws {
122
+ onMainActor { browserManager.setFavorites(favorites) }
123
+ }
124
+
125
+ // MARK: - Gate
126
+
127
+ func setGate(gate: Gate?, hasResolver: Bool) throws {
128
+ onMainActor { browseGate.set(chrome: gate, hasResolver: hasResolver) }
129
+ }
130
+
131
+ func clearGate() throws {
132
+ onMainActor { browseGate.clear() }
133
+ }
134
+
135
+ // MARK: - Car Connection (CarPlay)
136
+
137
+ /// Static because the CarPlay scene can connect before the JS runtime
138
+ /// creates the shared instance (cold start in the car).
139
+ internal nonisolated(unsafe) static var carPlayConnected = false
140
+
141
+ /// Called by the CarPlay scene delegate (via `RNABCarPlayController`) on
142
+ /// scene connect/disconnect — not from controller start/stop, which also
143
+ /// cycle on a JS reload and would emit spurious connection changes.
144
+ internal static func setCarPlayConnected(_ connected: Bool) {
145
+ guard carPlayConnected != connected else { return }
146
+ carPlayConnected = connected
147
+ shared?.onCarConnectedChanged(connected)
148
+ }
149
+
150
+ func isCarConnected() throws -> Bool {
151
+ HybridAudioBrowser.carPlayConnected
152
+ }
153
+ }
@@ -0,0 +1,144 @@
1
+ import AVFoundation
2
+ import AVKit
3
+ import Foundation
4
+ import MediaPlayer
5
+ import NitroModules
6
+ import os.log
7
+
8
+ public extension HybridAudioBrowser {
9
+ // MARK: - Player Setup
10
+
11
+ func setupPlayer(options: NativeSetupPlayerOptions) throws -> Promise<Void> {
12
+ Promise.async {
13
+ // Create player and configure on main actor
14
+ await MainActor.run { [weak self] in
15
+ guard let self else { return }
16
+ // Set the category now, but activate only when playback produces output.
17
+ self.audioSession.configure(options.ios)
18
+
19
+ // A re-setup REPLACES the player, so the outgoing one has to be torn down
20
+ // and un-published first. No-op on first setup.
21
+ destroyPlayer()
22
+
23
+ let newEngine = AVPlayerEngine(callbacks: self, resolver: browserManager.requests)
24
+ if let buffer = options.ios?.buffer { newEngine.bufferDuration = buffer }
25
+ newEngine.nowPlayingUpdater.enabled = options.autoUpdateNowPlayingMetadata ?? true
26
+ newEngine.nowPlayingUpdater.formatter = options.nowPlayingMetadataFormatter
27
+ newEngine.nowPlayingUpdater.isOnline = networkMonitor.isOnline
28
+ let newPlayer = Player(engine: newEngine)
29
+ newPlayer.callbacks = self
30
+ newPlayer.retry.networkMonitor = networkMonitor
31
+ if let retry = options.retry { newPlayer.retry.updatePolicy(from: retry) }
32
+ newEngine.nowPlayingUpdater.source = newPlayer
33
+ engine = newEngine
34
+ player = newPlayer
35
+ audioSession.player = newPlayer
36
+
37
+ // Connectivity fans out here, whether or not JS subscribes: the JS event, the
38
+ // now-playing stall line, and reconnecting a stalled stream. The monitor mutates on main.
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.
52
+
53
+ // The bundled runtime options and initial state are part of the atomic launch
54
+ // description. Stage the state first so setup options and earlier pre-setup setter
55
+ // 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
+ }
63
+ if let mode = options.repeatMode { pendingRepeatMode = mode }
64
+ if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
65
+ applyPendingPlayerState()
66
+
67
+ // Push the resolved event cadence unconditionally. `updateOptions` applies
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)
75
+ playerAndConfiguredBrowser.check()
76
+ }
77
+ }
78
+ }
79
+
80
+ /// Applies initial player state staged before the player existed — from setup options or the
81
+ /// imperative setters called pre-setup. Consumed on apply so a later re-setup doesn't replay
82
+ /// stale state.
83
+ @MainActor
84
+ private func applyPendingPlayerState() {
85
+ guard let player else { return }
86
+ if let mode = pendingRepeatMode {
87
+ player.repeatMode = mode
88
+ pendingRepeatMode = nil
89
+ }
90
+ if let intent = pendingPlayWhenReady {
91
+ player.setPlayWhenReady(intent)
92
+ pendingPlayWhenReady = nil
93
+ }
94
+ }
95
+
96
+ func updateOptions(options: NativeUpdateOptions) {
97
+ onMainActor {
98
+ let previousInterval = playerOptions.progressUpdateEventInterval
99
+
100
+ // Update stored options
101
+ 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
+
115
+ onOptionsChanged(playerOptions.toOptions())
116
+ playerOptionsChangedEmitter.emit(())
117
+ }
118
+ }
119
+
120
+ /// Converts capabilities to remote commands and applies them to the player
121
+ @MainActor
122
+ private func applyRemoteCommands() {
123
+ guard let engine else { return }
124
+
125
+ let remoteCommands = playerOptions.capabilities.buildRemoteCommands(
126
+ forwardJumpInterval: NSNumber(value: playerOptions.forwardJumpInterval),
127
+ backwardJumpInterval: NSNumber(value: playerOptions.backwardJumpInterval),
128
+ playbackRates: playerOptions.playbackRates,
129
+ )
130
+
131
+ engine.remoteCommands = remoteCommands
132
+ }
133
+
134
+ func getOptions() throws -> Options {
135
+ onMainActor { playerOptions.toOptions() }
136
+ }
137
+
138
+ func setPlaybackIntervalEnabled(enabled: Bool) {
139
+ onMainActor {
140
+ playbackIntervalEnabled = enabled
141
+ player?.setPlaybackIntervalEnabled(enabled)
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,397 @@
1
+ import AVFoundation
2
+ import AVKit
3
+ import Foundation
4
+ import MediaPlayer
5
+ import NitroModules
6
+ import os.log
7
+
8
+ public extension HybridAudioBrowser {
9
+ // MARK: - Playback Control
10
+
11
+ func load(track: Track) throws {
12
+ try onMainActor {
13
+ guard let player else {
14
+ throw NSError(domain: "AudioBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Player not initialized"])
15
+ }
16
+ player.load(track)
17
+ }
18
+ }
19
+
20
+ func reset() throws {
21
+ onMainActor { player?.clear() }
22
+ }
23
+
24
+ func play() throws {
25
+ onMainActor { player?.play() }
26
+ }
27
+
28
+ func pause() throws {
29
+ onMainActor { player?.pause() }
30
+ }
31
+
32
+ func togglePlayback() throws {
33
+ onMainActor { player?.togglePlayback() }
34
+ }
35
+
36
+ func stop() throws {
37
+ onMainActor { player?.stop() }
38
+ }
39
+
40
+ func setPlayWhenReady(playWhenReady: Bool) throws {
41
+ onMainActor {
42
+ // Pre-setup this stages the intent for the player to come up with — never a no-op.
43
+ if let player { player.setPlayWhenReady(playWhenReady) }
44
+ else { pendingPlayWhenReady = playWhenReady }
45
+ }
46
+ }
47
+
48
+ func getPlayWhenReady() throws -> Bool {
49
+ onMainActor { player?.playWhenReady ?? pendingPlayWhenReady ?? false }
50
+ }
51
+
52
+ func seekTo(position: Double) throws {
53
+ onMainActor { player?.seek(to: position) }
54
+ }
55
+
56
+ func seekBy(offset: Double) throws {
57
+ onMainActor { player?.seek(by: offset) }
58
+ }
59
+
60
+ func seekToLiveEdge() throws {
61
+ onMainActor { player?.seekToLiveEdge() }
62
+ }
63
+
64
+ func setVolume(level: Double) throws {
65
+ onMainActor { engine?.volume = Float(level) }
66
+ }
67
+
68
+ func getVolume() throws -> Double {
69
+ onMainActor { Double(engine?.volume ?? 1.0) }
70
+ }
71
+
72
+ func setRate(rate: Double) throws {
73
+ onMainActor { player?.rate = Float(rate) }
74
+ }
75
+
76
+ func getRate() throws -> Double {
77
+ onMainActor { Double(player?.rate ?? 1.0) }
78
+ }
79
+
80
+ func getProgress() throws -> Progress {
81
+ onMainActor {
82
+ Progress(
83
+ position: player?.currentTime ?? 0,
84
+ duration: engine?.duration ?? 0,
85
+ buffered: engine?.bufferedPosition ?? 0,
86
+ )
87
+ }
88
+ }
89
+
90
+ func getPlayback() throws -> Playback {
91
+ onMainActor { player?.getPlayback() ?? Playback(state: .none, error: nil) }
92
+ }
93
+
94
+ func getPlayingState() throws -> PlayingState {
95
+ onMainActor {
96
+ player?.playingState ?? PlayingState(playing: false, buffering: false)
97
+ }
98
+ }
99
+
100
+ func getRepeatMode() throws -> RepeatMode {
101
+ onMainActor { player?.repeatMode ?? pendingRepeatMode ?? .off }
102
+ }
103
+
104
+ func setRepeatMode(mode: RepeatMode) throws {
105
+ onMainActor {
106
+ // Pre-setup this stages the mode for the player to come up with — never a no-op.
107
+ if let player { player.repeatMode = mode }
108
+ else { pendingRepeatMode = mode }
109
+ }
110
+ }
111
+
112
+ func getShuffleEnabled() throws -> Bool {
113
+ onMainActor { player?.shuffleEnabled ?? false }
114
+ }
115
+
116
+ func setShuffleEnabled(enabled: Bool) throws {
117
+ onMainActor { player?.shuffleEnabled = enabled }
118
+ }
119
+
120
+ func getPlaybackError() throws -> PlaybackError? {
121
+ onMainActor { player?.nitroPlaybackError }
122
+ }
123
+
124
+ func retry() throws {
125
+ // Re-resolve rather than replay the cached URL: short-lived URLs/tokens
126
+ // may have expired since the failure (matches the internal retry path).
127
+ onMainActor { player?.reloadResolving(startFromCurrentTime: true) }
128
+ }
129
+
130
+ // MARK: - Sleep Timer
131
+
132
+ func getSleepTimer() throws -> SleepTimer {
133
+ onMainActor {
134
+ player?.sleepTimer ?? .first(NullType.null)
135
+ }
136
+ }
137
+
138
+ func setSleepTimer(seconds: Double, fadeDuration: Double?) throws {
139
+ onMainActor {
140
+ player?.sleepTimerManager.set(seconds: seconds, fadeDuration: fadeDuration)
141
+ }
142
+ }
143
+
144
+ func setSleepTimerToEndOfTrack() throws {
145
+ onMainActor {
146
+ player?.sleepTimerManager.setToEndOfTrack()
147
+ }
148
+ }
149
+
150
+ func clearSleepTimer() throws -> Bool {
151
+ onMainActor {
152
+ player?.sleepTimerManager.clear() ?? false
153
+ }
154
+ }
155
+
156
+ // MARK: - Queue Management
157
+
158
+ func add(tracks: [Track], insertBeforeIndex: Double?) throws {
159
+ try onMainActor {
160
+ guard let player else { return }
161
+ if let index = insertBeforeIndex {
162
+ try player.add(tracks, at: Int(index))
163
+ } else {
164
+ player.add(tracks)
165
+ }
166
+ }
167
+ }
168
+
169
+ func move(fromIndex: Double, toIndex: Double) throws {
170
+ try onMainActor {
171
+ try player?.move(fromIndex: Int(fromIndex), toIndex: Int(toIndex))
172
+ }
173
+ }
174
+
175
+ func remove(indexes: [Double]) throws {
176
+ try onMainActor {
177
+ guard let player else { return }
178
+ // Remove in reverse order to maintain index validity
179
+ for index in indexes.sorted().reversed() {
180
+ try player.remove(Int(index))
181
+ }
182
+ }
183
+ }
184
+
185
+ func removeUpcomingTracks() throws {
186
+ onMainActor { player?.removeUpcomingTracks() }
187
+ }
188
+
189
+ func skip(index: Double, initialPosition: Double?) throws {
190
+ try onMainActor {
191
+ try player?.skipTo(Int(index))
192
+ if let position = initialPosition {
193
+ player?.seek(to: position)
194
+ }
195
+ }
196
+ }
197
+
198
+ func skipToNext(initialPosition: Double?) throws {
199
+ onMainActor {
200
+ player?.next()
201
+ if let position = initialPosition {
202
+ player?.seek(to: position)
203
+ }
204
+ }
205
+ }
206
+
207
+ func skipToPrevious(initialPosition: Double?) throws {
208
+ onMainActor {
209
+ player?.previous()
210
+ if let position = initialPosition {
211
+ player?.seek(to: position)
212
+ }
213
+ }
214
+ }
215
+
216
+ func setActiveTrackFavorited(favorited: Bool) throws {
217
+ try favoriteActiveTrack(favorited)
218
+ }
219
+
220
+ /// Sets the active track's favorite state: in the favorite set first, so the now-playing
221
+ /// heart (hydrated from it) flips at once, then on the queued track. False when nothing is
222
+ /// playing or the track has no identity.
223
+ @discardableResult
224
+ internal func favoriteActiveTrack(_ favorited: Bool) throws -> Bool {
225
+ try onMainActor {
226
+ guard let player, let track = player.currentTrack, let identity = track.identity else { return false }
227
+ browserManager.setFavorited(identity: identity, favorited: favorited)
228
+ // Back in the queue carrying the flag, since hydration never overrides a track's own value.
229
+ let updated = track.copying(favorited: favorited)
230
+ try player.replace(player.currentIndex, updated)
231
+ // The only emit: a favorite toggle mutates the active track in place, so
232
+ // onPlaybackActiveTrackChanged stays transition-only.
233
+ favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updated, favorited: favorited))
234
+ return true
235
+ }
236
+ }
237
+
238
+ /// Whether the active (now-playing) track is favorited, per the authoritative
239
+ /// favorite set (via `getActiveTrack`'s hydration).
240
+ internal func isActiveTrackFavorited() -> Bool {
241
+ (try? getActiveTrack())?.favorited ?? false
242
+ }
243
+
244
+ func toggleActiveTrackFavorited() throws {
245
+ try favoriteActiveTrack(!isActiveTrackFavorited())
246
+ }
247
+
248
+ func setQueue(tracks: [Track], startIndex: Double?, startPosition: Double?) throws {
249
+ onMainActor {
250
+ guard let player else { return }
251
+ player.setQueue(
252
+ tracks,
253
+ initialIndex: startIndex.map { Int($0) } ?? 0,
254
+ startPositionMs: startPosition.map { $0 * 1000 },
255
+ )
256
+ }
257
+ }
258
+
259
+ func getQueue() throws -> [Track] {
260
+ onMainActor { player?.tracks ?? [] }
261
+ }
262
+
263
+ func getTrack(index: Double) throws -> Track? {
264
+ onMainActor {
265
+ guard let tracks = player?.tracks else { return nil }
266
+ let i = Int(index)
267
+ guard i >= 0, i < tracks.count else { return nil }
268
+ return tracks[i]
269
+ }
270
+ }
271
+
272
+ func getActiveTrackIndex() throws -> Double? {
273
+ onMainActor {
274
+ guard let index = player?.currentIndex, index >= 0 else { return nil }
275
+ return Double(index)
276
+ }
277
+ }
278
+
279
+ func getActiveTrack() throws -> Track? {
280
+ // Hydrate `favorited` from the authoritative favorite set — the now-playing
281
+ // track is loaded outside the browse cache, so its own flag isn't otherwise
282
+ // kept in sync as favorites change. This is the single source of truth for
283
+ // "is the active track favorited" (now-playing heart, toggle direction, JS).
284
+ onMainActor { player?.currentTrack.map(browserManager.hydrateFavorite) }
285
+ }
286
+
287
+ // MARK: - Now Playing
288
+
289
+ func updateNowPlaying(update: NowPlayingUpdate?) throws {
290
+ onMainActor { engine?.nowPlayingUpdater.setOverride(update) }
291
+ }
292
+
293
+ func flashNowPlaying(update: NowPlayingUpdate, durationMs: Double) throws {
294
+ onMainActor { engine?.nowPlayingUpdater.flash(update, durationMs: durationMs) }
295
+ }
296
+
297
+ func clearNowPlayingFlash() throws {
298
+ onMainActor { engine?.nowPlayingUpdater.clearFlash() }
299
+ }
300
+
301
+ func getNowPlaying() throws -> NowPlayingMetadata? {
302
+ onMainActor { engine?.nowPlayingUpdater.nowPlaying }
303
+ }
304
+
305
+ // MARK: - Network
306
+
307
+ func getOnline() throws -> Bool {
308
+ // No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
309
+ // cheap read on the JS thread instead of blocking on the main queue.
310
+ networkMonitor.isOnline
311
+ }
312
+
313
+ // MARK: - System Volume
314
+
315
+ func getSystemVolume() throws -> Double {
316
+ AudioSessionController.volume
317
+ }
318
+
319
+ func setSystemVolume(volume _: Double) throws {
320
+ // iOS doesn't provide a public API to set system volume programmatically.
321
+ // Users must adjust volume via hardware buttons or Control Center.
322
+ logger.debug("setSystemVolume is not supported on iOS - volume must be adjusted via hardware buttons or Control Center")
323
+ }
324
+
325
+ // MARK: - External Audio Output
326
+
327
+ func getOutput() throws -> Output? {
328
+ AudioSessionController.currentOutput
329
+ }
330
+
331
+ func supportsOutputSwitcher() throws -> Bool {
332
+ // iOS can always present the system route picker (AVRoutePickerView).
333
+ true
334
+ }
335
+
336
+ func openOutputPicker() throws {
337
+ DispatchQueue.main.async { [weak self] in
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)]"
396
+ }
397
+ }