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.
- package/android/src/main/java/com/audiobrowser/browser/BrowserUrlResolution.kt +3 -4
- 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
|
@@ -5,7 +5,7 @@ import MediaPlayer
|
|
|
5
5
|
import NitroModules
|
|
6
6
|
import os.log
|
|
7
7
|
|
|
8
|
-
@MainActor let playerAndConfiguredBrowser =
|
|
8
|
+
@MainActor let playerAndConfiguredBrowser = ReadyValue<(HybridAudioBrowser, Player)> {
|
|
9
9
|
guard let browser = HybridAudioBrowser.shared,
|
|
10
10
|
browser.browserManager.isConfigured,
|
|
11
11
|
let player = browser.getPlayer() else { return nil }
|
|
@@ -21,7 +21,7 @@ private struct UncheckedSendableBox<T>: @unchecked Sendable { let value: T }
|
|
|
21
21
|
/// can't be `@MainActor`. Isolation is therefore per-property, and `@unchecked`
|
|
22
22
|
/// means the compiler won't tell you when a new one needs it.
|
|
23
23
|
public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
24
|
-
|
|
24
|
+
let logger = Logger(subsystem: "com.audiobrowser", category: "AudioBrowser")
|
|
25
25
|
|
|
26
26
|
// MARK: - Shared Instance for CarPlay
|
|
27
27
|
|
|
@@ -56,30 +56,20 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
56
56
|
|
|
57
57
|
/// Main-confined by convention, not annotation: `deinit` reads it as a
|
|
58
58
|
/// safety net and can't hop. Reach it from `onMainActor` / `@MainActor`.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
var player: Player?
|
|
60
|
+
/// The media stack under `player`, for what only AVFoundation has: now playing, buffering,
|
|
61
|
+
/// volume and the remote commands.
|
|
62
|
+
var engine: AVPlayerEngine?
|
|
63
|
+
let networkMonitor = NetworkMonitor()
|
|
62
64
|
let browserManager = BrowserManager()
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
private var
|
|
66
|
-
private var interruptionObserver: NSObjectProtocol?
|
|
67
|
-
private var mediaServicesResetObserver: NSObjectProtocol?
|
|
68
|
-
/// Re-applies our resolved audio-session category. Captured at setup so a
|
|
69
|
-
/// media-services reset (which clears the category) can restore it.
|
|
70
|
-
@MainActor private var applyAudioSessionCategory: () -> Void = {}
|
|
71
|
-
@MainActor private var nowPlayingOverride: NowPlayingUpdate?
|
|
72
|
-
/// When false, the now-playing surface uses the raw track fields (override + formatter ignored).
|
|
73
|
-
@MainActor private var nowPlayingMetadataEnabled = true
|
|
74
|
-
/// Customizes the now-playing title/subtitle from the track + live timed metadata + playback state.
|
|
75
|
-
@MainActor private var nowPlayingMetadataFormatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
|
|
65
|
+
let browseGate = BrowseGate()
|
|
66
|
+
let trackSelector: TrackSelector
|
|
67
|
+
@MainActor private(set) lazy var audioSession = AudioSessionController()
|
|
76
68
|
/// Initial player state staged before the player exists — from setup options or the imperative
|
|
77
69
|
/// setters called pre-setup. Strict last-write-wins; consumed when the player comes up.
|
|
78
|
-
@MainActor
|
|
79
|
-
@MainActor
|
|
80
|
-
|
|
81
|
-
@MainActor private var latestTimedMetadata: TimedMetadata?
|
|
82
|
-
@MainActor private let playerOptions = PlayerUpdateOptions()
|
|
70
|
+
@MainActor var pendingPlayWhenReady: Bool?
|
|
71
|
+
@MainActor var pendingRepeatMode: RepeatMode?
|
|
72
|
+
@MainActor let playerOptions = PlayerUpdateOptions()
|
|
83
73
|
|
|
84
74
|
/// Configured playback rates for the playback-rate capability (for CarPlay rate cycling)
|
|
85
75
|
@MainActor var playbackRates: [Double] { playerOptions.playbackRates }
|
|
@@ -92,31 +82,14 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
92
82
|
playerOptions.carPlayPlayingIndicatorLocation
|
|
93
83
|
}
|
|
94
84
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
didSet {
|
|
100
|
-
// Skip if both nil (no real change)
|
|
101
|
-
guard oldValue != nil || lastNavigationError != nil else { return }
|
|
102
|
-
navigationErrorEmitter.emit(NavigationErrorEvent(error: lastNavigationError))
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/// Isolated for the same reason as ``lastNavigationError``.
|
|
107
|
-
@MainActor private var lastFormattedNavigationError: FormattedNavigationError? {
|
|
108
|
-
didSet {
|
|
109
|
-
// Skip if both nil (no real change)
|
|
110
|
-
guard oldValue != nil || lastFormattedNavigationError != nil else { return }
|
|
111
|
-
onFormattedNavigationError(lastFormattedNavigationError)
|
|
112
|
-
}
|
|
113
|
-
}
|
|
85
|
+
@MainActor lazy var navigationError = NavigationErrorState(
|
|
86
|
+
onErrorChanged: { [weak self] in self?.navigationErrorEmitter.emit(NavigationErrorEvent(error: $0)) },
|
|
87
|
+
onFormattedChanged: { [weak self] in self?.onFormattedNavigationError($0) },
|
|
88
|
+
)
|
|
114
89
|
|
|
115
90
|
// MARK: - Multi-Listener Emitters
|
|
116
91
|
|
|
117
92
|
/// Emitters allow multiple listeners for each event type, avoiding callback hijacking
|
|
118
|
-
public let tabsChangedEmitter = Emitter<[Track]>()
|
|
119
|
-
public let contentChangedEmitter = Emitter<ResolvedTrack?>()
|
|
120
93
|
public let favoriteChangedEmitter = Emitter<FavoriteChangedEvent>()
|
|
121
94
|
public let activeTrackChangedEmitter = Emitter<PlaybackActiveTrackChangedEvent>()
|
|
122
95
|
public let queueChangedEmitter = Emitter<[Track]>()
|
|
@@ -124,11 +97,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
124
97
|
public let repeatModeChangedEmitter = Emitter<RepeatModeChangedEvent>()
|
|
125
98
|
public let shuffleChangedEmitter = Emitter<Bool>()
|
|
126
99
|
public let externalContentChangedEmitter = Emitter<String>()
|
|
127
|
-
///
|
|
128
|
-
/// the active flag; per-request chrome is obtained at each serve site via
|
|
129
|
-
/// `gateDecision(for:)`, so the emitter only tells surfaces to re-render.
|
|
130
|
-
public let gateChangedEmitter = Emitter<Bool>()
|
|
131
|
-
/// Fired when a voice media intent (`handlePlayMediaIntent`) successfully
|
|
100
|
+
/// Fired when a voice media intent (`MediaIntentPlayback`) successfully
|
|
132
101
|
/// starts playback. CarPlay surfaces the Now Playing template in response, so
|
|
133
102
|
/// the user lands on the playing station (with the rest of the results in Up
|
|
134
103
|
/// Next) rather than the screen they invoked Siri from. No-op where unobserved.
|
|
@@ -142,7 +111,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
142
111
|
|
|
143
112
|
/// Runs a closure on MainActor synchronously. Safe for non-Sendable return types
|
|
144
113
|
/// because DispatchQueue.main.sync blocks the caller — no concurrent access window.
|
|
145
|
-
|
|
114
|
+
func onMainActor<T>(_ work: @MainActor () -> T) -> T {
|
|
146
115
|
if Thread.isMainThread {
|
|
147
116
|
MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
|
|
148
117
|
} else {
|
|
@@ -152,7 +121,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
152
121
|
}
|
|
153
122
|
}
|
|
154
123
|
|
|
155
|
-
|
|
124
|
+
func onMainActor<T>(_ work: @MainActor () throws -> T) throws -> T {
|
|
156
125
|
if Thread.isMainThread {
|
|
157
126
|
try MainActor.assumeIsolated { try UncheckedSendableBox(value: work()) }.value
|
|
158
127
|
} else {
|
|
@@ -165,21 +134,15 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
165
134
|
// MARK: - Browser Properties
|
|
166
135
|
|
|
167
136
|
public var path: String? {
|
|
168
|
-
get { onMainActor { browserManager.
|
|
137
|
+
get { onMainActor { browserManager.path } }
|
|
169
138
|
set {
|
|
170
139
|
guard let newPath = newValue else { return }
|
|
171
|
-
|
|
172
|
-
do {
|
|
173
|
-
try await browserManager.navigate(newPath)
|
|
174
|
-
} catch {
|
|
175
|
-
handleNavigationError(error, path: newPath)
|
|
176
|
-
}
|
|
177
|
-
}
|
|
140
|
+
navigate(newPath, clearError: false)
|
|
178
141
|
}
|
|
179
142
|
}
|
|
180
143
|
|
|
181
144
|
public var tabs: [Track]? {
|
|
182
|
-
get { onMainActor { browserManager.
|
|
145
|
+
get { onMainActor { browserManager.tabs } }
|
|
183
146
|
set { /* tabs are managed internally by browserManager */ }
|
|
184
147
|
}
|
|
185
148
|
|
|
@@ -204,14 +167,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
204
167
|
Task {
|
|
205
168
|
let tabs = try? await browserManager.queryTabs()
|
|
206
169
|
// Navigate to configured path, first tab, or "/"
|
|
207
|
-
|
|
208
|
-
// Clear error before navigation (matches Kotlin clearNavigationError())
|
|
209
|
-
clearNavigationError()
|
|
210
|
-
do {
|
|
211
|
-
try await browserManager.navigate(initialPath)
|
|
212
|
-
} catch {
|
|
213
|
-
handleNavigationError(error, path: initialPath)
|
|
214
|
-
}
|
|
170
|
+
navigate(configuredPath ?? tabs?.first?.path ?? "/", clearError: true)
|
|
215
171
|
}
|
|
216
172
|
}
|
|
217
173
|
}
|
|
@@ -231,11 +187,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
231
187
|
/// JS-side Promise, the inner to the decision.
|
|
232
188
|
///
|
|
233
189
|
/// Default fails CLOSED (`gated: true`). This default is only consulted in the
|
|
234
|
-
/// init window where
|
|
190
|
+
/// init window where the gate is already `.resolved` but JS hasn't
|
|
235
191
|
/// (re)bound the real resolver yet (JS reload / instance churn re-seeding gate
|
|
236
|
-
/// state, e.g. CarPlay
|
|
192
|
+
/// state, e.g. CarPlay deciding its tabs at scene connect). Serving content
|
|
237
193
|
/// in that window would be a fail-open leak — same direction as the
|
|
238
|
-
/// resolver-error path in `
|
|
194
|
+
/// resolver-error path in `BrowseGate.decide`. Matches Android's default.
|
|
239
195
|
public var resolveGate: (NativeGateRequest) -> Promise<Promise<GateDecision>> = HybridAudioBrowser.defaultResolveGate
|
|
240
196
|
|
|
241
197
|
// Computed, not stored: a nonisolated static holding a non-Sendable closure
|
|
@@ -312,36 +268,44 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
312
268
|
public var onEqualizerChanged: (EqualizerSettings) -> Void = { _ in }
|
|
313
269
|
public var onBatteryWarningPendingChanged: (BatteryWarningPendingChangedEvent) -> Void = { _ in }
|
|
314
270
|
public var onBatteryOptimizationStatusChanged: (BatteryOptimizationStatusChangedEvent) -> Void = { _ in }
|
|
315
|
-
public var onSystemVolumeChanged: (Double) -> Void = { _ in }
|
|
316
|
-
didSet {
|
|
317
|
-
setupVolumeObserver()
|
|
318
|
-
}
|
|
319
|
-
}
|
|
271
|
+
public var onSystemVolumeChanged: (Double) -> Void = { _ in }
|
|
320
272
|
|
|
321
|
-
public var onOutputChanged: (Output) -> Void = { _ in }
|
|
273
|
+
public var onOutputChanged: (Output) -> Void = { _ in }
|
|
274
|
+
|
|
275
|
+
public var onCarConnectedChanged: (Bool) -> Void = { _ in } {
|
|
322
276
|
didSet {
|
|
323
|
-
|
|
277
|
+
// Immediately notify current state (the scene may have connected before
|
|
278
|
+
// this JS runtime subscribed).
|
|
279
|
+
onCarConnectedChanged(HybridAudioBrowser.carPlayConnected)
|
|
324
280
|
}
|
|
325
281
|
}
|
|
326
282
|
|
|
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
|
+
|
|
327
288
|
// MARK: - Initialization
|
|
328
289
|
|
|
329
290
|
override public init() {
|
|
330
291
|
trackSelector = TrackSelector(browserManager: browserManager)
|
|
331
292
|
super.init()
|
|
332
293
|
|
|
333
|
-
// Clean up the previous instance (e.g., on JS runtime reload)
|
|
334
|
-
//
|
|
335
|
-
// playerAndConfiguredBrowser OnceValue, so we must explicitly stop
|
|
336
|
-
// its player to prevent two audio streams running simultaneously.
|
|
294
|
+
// Clean up the previous instance (e.g., on JS runtime reload): stop its
|
|
295
|
+
// player so two audio streams never run at once.
|
|
337
296
|
onMainActor {
|
|
338
297
|
// Statics are lazy — touch the observer so it's installed from the first
|
|
339
298
|
// instance onward.
|
|
340
299
|
_ = HybridAudioBrowser.reloadObserver
|
|
341
|
-
HybridAudioBrowser.shared?.
|
|
300
|
+
HybridAudioBrowser.shared?.destroyPlayer()
|
|
342
301
|
HybridAudioBrowser.shared?.dropStaleJSCallbacks()
|
|
343
|
-
playerAndConfiguredBrowser.reset()
|
|
344
302
|
HybridAudioBrowser.shared = self
|
|
303
|
+
audioSession.outputChanged.addListener { [weak self] in self?.onOutputChanged($0) }
|
|
304
|
+
audioSession.volumeChanged.addListener { [weak self] in self?.onSystemVolumeChanged($0) }
|
|
305
|
+
browseGate.resolve = { [weak self] request in
|
|
306
|
+
guard let self else { throw CancellationError() }
|
|
307
|
+
return try await resolveGate(request).await().await()
|
|
308
|
+
}
|
|
345
309
|
// Tell connected external controllers (CarPlay) to re-subscribe against
|
|
346
310
|
// this instance. Unconditional: `shared` is weak, so on a JS reload the
|
|
347
311
|
// old instance may already be gone — "shared was nil" does not mean no
|
|
@@ -349,7 +313,6 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
349
313
|
HybridAudioBrowser.instanceChangedEmitter.emit(())
|
|
350
314
|
}
|
|
351
315
|
setupEmitterToNitroForwarding()
|
|
352
|
-
setupBrowserCallbacks()
|
|
353
316
|
}
|
|
354
317
|
|
|
355
318
|
/// Drops every JS-provided callback that native code can invoke on its own
|
|
@@ -365,7 +328,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
365
328
|
/// reverts to the fail-closed default it documents for exactly this window.
|
|
366
329
|
/// One-shot: on a normal reload both triggers hit the same dying instance
|
|
367
330
|
/// (the reload notification, then the replacement's `init`), and the second
|
|
368
|
-
/// strip would re-fire the config-setter cascade — another `
|
|
331
|
+
/// strip would re-fire the config-setter cascade — another `configChanged`
|
|
369
332
|
/// → CarPlay now-playing button rebuild — for nothing.
|
|
370
333
|
@MainActor private var didDropJSCallbacks = false
|
|
371
334
|
|
|
@@ -381,46 +344,45 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
381
344
|
browserManager.config = browserManager.config.strippingJSCallbacks()
|
|
382
345
|
}
|
|
383
346
|
resolveGate = Self.defaultResolveGate
|
|
384
|
-
|
|
347
|
+
engine?.nowPlayingUpdater.formatter = nil
|
|
385
348
|
}
|
|
386
349
|
|
|
387
350
|
deinit {
|
|
388
|
-
// Block-based notification observers are retained by the center until an
|
|
389
|
-
// explicit removeObserver — observer death isn't detected — so without this
|
|
390
|
-
// every JS reload strands another token + closure. They capture `self`
|
|
391
|
-
// weakly, so a stranded one no-ops rather than misbehaving; what accumulates
|
|
392
|
-
// is the tokens and a wasted main-queue dispatch per interruption / route
|
|
393
|
-
// change / media-services reset. (`volumeObservation` needs no counterpart:
|
|
394
|
-
// NSKeyValueObservation invalidates itself when this instance releases it.)
|
|
395
|
-
for observer in [interruptionObserver, routeChangeObserver, mediaServicesResetObserver]
|
|
396
|
-
.compactMap(\.self)
|
|
397
|
-
{
|
|
398
|
-
NotificationCenter.default.removeObserver(observer)
|
|
399
|
-
}
|
|
400
|
-
|
|
401
351
|
// Safety net: ensure the AVPlayer is stopped if this instance is deallocated.
|
|
402
|
-
let player = self.player
|
|
352
|
+
let player = self.player, engine = self.engine
|
|
403
353
|
if Thread.isMainThread {
|
|
404
|
-
MainActor.assumeIsolated {
|
|
354
|
+
MainActor.assumeIsolated {
|
|
355
|
+
player?.clear()
|
|
356
|
+
engine?.destroy()
|
|
357
|
+
}
|
|
405
358
|
} else {
|
|
406
|
-
DispatchQueue.main.async {
|
|
359
|
+
DispatchQueue.main.async {
|
|
360
|
+
MainActor.assumeIsolated {
|
|
361
|
+
player?.clear()
|
|
362
|
+
engine?.destroy()
|
|
363
|
+
}
|
|
364
|
+
}
|
|
407
365
|
}
|
|
408
366
|
}
|
|
409
367
|
|
|
410
|
-
/// Returns the
|
|
411
|
-
func getPlayer() ->
|
|
368
|
+
/// Returns the player, if setup has been called.
|
|
369
|
+
func getPlayer() -> Player? {
|
|
412
370
|
player
|
|
413
371
|
}
|
|
414
372
|
|
|
373
|
+
/// Stops the player and releases its media stack: the AVPlayer and the now-playing session
|
|
374
|
+
/// outlive a dropped reference otherwise.
|
|
375
|
+
@MainActor
|
|
376
|
+
func destroyPlayer() {
|
|
377
|
+
player?.clear()
|
|
378
|
+
engine?.destroy()
|
|
379
|
+
}
|
|
380
|
+
|
|
415
381
|
/// Sets up automatic forwarding from emitters to Nitro callbacks
|
|
416
382
|
private func setupEmitterToNitroForwarding() {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
contentChangedEmitter.addListener { [weak self] content in
|
|
422
|
-
self?.onContentChanged(content)
|
|
423
|
-
}
|
|
383
|
+
browserManager.pathChanged.addListener { [weak self] in self?.onPathChanged($0) }
|
|
384
|
+
browserManager.tabsChanged.addListener { [weak self] in self?.onTabsChanged($0) }
|
|
385
|
+
browserManager.contentChanged.addListener { [weak self] in self?.onContentChanged($0) }
|
|
424
386
|
|
|
425
387
|
favoriteChangedEmitter.addListener { [weak self] event in
|
|
426
388
|
self?.onFavoriteChanged(event)
|
|
@@ -442,1581 +404,6 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
442
404
|
self?.onPlaybackRepeatModeChanged(event)
|
|
443
405
|
}
|
|
444
406
|
}
|
|
445
|
-
|
|
446
|
-
private func setupBrowserCallbacks() {
|
|
447
|
-
onMainActor {
|
|
448
|
-
browserManager.onPathChanged = { [weak self] path in
|
|
449
|
-
self?.onPathChanged(path)
|
|
450
|
-
}
|
|
451
|
-
browserManager.onContentChanged = { [weak self] content in
|
|
452
|
-
self?.contentChangedEmitter.emit(content)
|
|
453
|
-
}
|
|
454
|
-
browserManager.onTabsChanged = { [weak self] tabs in
|
|
455
|
-
self?.tabsChangedEmitter.emit(tabs)
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
private func handleNavigationError(_ error: Error, path: String) {
|
|
461
|
-
let navError = NavigationError.from(error)
|
|
462
|
-
|
|
463
|
-
onMainActor { lastNavigationError = navError }
|
|
464
|
-
|
|
465
|
-
// Format the error (async if using JS callback, sync for defaults)
|
|
466
|
-
let defaultFormatted = navError.defaultFormatted()
|
|
467
|
-
if let formatter = onMainActor({ browserManager.config.formatNavigationError }) {
|
|
468
|
-
let params = FormatNavigationErrorParams(
|
|
469
|
-
error: navError, defaultFormatted: defaultFormatted, path: path,
|
|
470
|
-
kind: NavigationErrorKind(path: path),
|
|
471
|
-
)
|
|
472
|
-
// Dispatch to main thread for the Nitro bridge call to avoid C++ noexcept crashes.
|
|
473
|
-
// The result callbacks are @Sendable: a plain closure would inherit MainActor
|
|
474
|
-
// isolation from this block, and Nitro resolves its promises synchronously on the
|
|
475
|
-
// JS thread — Swift 6.2's dynamic isolation check then traps on closure entry.
|
|
476
|
-
// They therefore hop back explicitly, like every other writer.
|
|
477
|
-
DispatchQueue.main.async { [weak self] in
|
|
478
|
-
formatter(params)
|
|
479
|
-
.then { @Sendable [weak self] customDisplay in
|
|
480
|
-
self?.setFormattedNavigationError(customDisplay ?? defaultFormatted)
|
|
481
|
-
}
|
|
482
|
-
.catch { @Sendable [weak self] _ in
|
|
483
|
-
self?.setFormattedNavigationError(defaultFormatted)
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
} else {
|
|
487
|
-
setFormattedNavigationError(defaultFormatted)
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/// Callable from any thread — the Nitro promise callbacks below resolve on
|
|
492
|
-
/// the JS thread.
|
|
493
|
-
private func setFormattedNavigationError(_ formatted: FormattedNavigationError?) {
|
|
494
|
-
onMainActor { lastFormattedNavigationError = formatted }
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
/// Synchronous on purpose: the `didSet`s notify JS, which has to happen
|
|
498
|
-
/// before the navigation that may set them again.
|
|
499
|
-
private func clearNavigationError() {
|
|
500
|
-
onMainActor {
|
|
501
|
-
lastNavigationError = nil
|
|
502
|
-
lastFormattedNavigationError = nil
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
// MARK: - Browser Methods
|
|
507
|
-
|
|
508
|
-
public func navigatePath(path: String) throws {
|
|
509
|
-
clearNavigationError()
|
|
510
|
-
Task {
|
|
511
|
-
do {
|
|
512
|
-
try await browserManager.navigate(path)
|
|
513
|
-
} catch {
|
|
514
|
-
handleNavigationError(error, path: path)
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
public func navigateTrack(track: Track) throws {
|
|
520
|
-
// @MainActor: `player` is main-confined, and `trackSelector.select` is
|
|
521
|
-
// MainActor anyway — a bare Task read the reference off the cooperative
|
|
522
|
-
// pool, racing the assignment in `setupPlayer`.
|
|
523
|
-
Task { @MainActor in
|
|
524
|
-
guard let player else { return }
|
|
525
|
-
let result = await trackSelector.select(track: track, player: player)
|
|
526
|
-
switch result {
|
|
527
|
-
case let .play(intent):
|
|
528
|
-
executePlayback(intent)
|
|
529
|
-
case .intercepted:
|
|
530
|
-
break
|
|
531
|
-
case let .browse(path):
|
|
532
|
-
navigateToBrowsablePath(path)
|
|
533
|
-
case .none:
|
|
534
|
-
break
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
private func executePlayback(_ intent: TrackSelector.PlaybackIntent) {
|
|
540
|
-
onMainActor {
|
|
541
|
-
switch intent {
|
|
542
|
-
case let .skipTo(index):
|
|
543
|
-
do {
|
|
544
|
-
try player?.skipTo(index, playWhenReady: true)
|
|
545
|
-
} catch {
|
|
546
|
-
logger.error("Failed to skip to track at index \(index): \(error)")
|
|
547
|
-
}
|
|
548
|
-
case let .setQueue(tracks, startIndex, sourcePath):
|
|
549
|
-
player?.setQueue(tracks, initialIndex: startIndex, playWhenReady: true, sourcePath: sourcePath)
|
|
550
|
-
case let .loadTrack(track):
|
|
551
|
-
player?.load(track, playWhenReady: true)
|
|
552
|
-
}
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
private func navigateToBrowsablePath(_ path: String) {
|
|
557
|
-
clearNavigationError()
|
|
558
|
-
Task {
|
|
559
|
-
do {
|
|
560
|
-
try await browserManager.navigate(path)
|
|
561
|
-
} catch {
|
|
562
|
-
handleNavigationError(error, path: path)
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
public func search(query: String) throws -> Promise<[Track]> {
|
|
568
|
-
Promise.async { [weak self] in
|
|
569
|
-
guard let self else { return [] }
|
|
570
|
-
let resolved = try await browserManager.search(query)
|
|
571
|
-
return resolved.flattenedChildren ?? []
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
public func getContent() throws -> ResolvedTrack? {
|
|
576
|
-
onMainActor { browserManager.getContent() }
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
public func getNavigationError() throws -> NavigationError? {
|
|
580
|
-
onMainActor { lastNavigationError }
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
public func getFormattedNavigationError() throws -> FormattedNavigationError? {
|
|
584
|
-
onMainActor { lastFormattedNavigationError }
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
/// Internal signal sent on `externalContentChangedEmitter` to tell CarPlay to
|
|
588
|
-
/// refresh every displayed template. Not a real path (paths are always
|
|
589
|
-
/// `/…`), so it can't collide with one; kept internal — the public API is
|
|
590
|
-
/// `invalidateAllContent()`.
|
|
591
|
-
static let invalidateAllSentinel = "__rnab_invalidate_all__"
|
|
592
|
-
|
|
593
|
-
public func notifyContentChanged(path: String) throws {
|
|
594
|
-
// Throws on a contextual path (matching Android's `require`) — the caller
|
|
595
|
-
// must pass the container path, or the invalidation silently hits nothing.
|
|
596
|
-
try onMainActor { try browserManager.invalidateContentCache(path) }
|
|
597
|
-
|
|
598
|
-
// Notify external controllers (CarPlay) that content changed
|
|
599
|
-
externalContentChangedEmitter.emit(path)
|
|
600
|
-
|
|
601
|
-
// Re-resolve the path if it's the current browser path
|
|
602
|
-
if onMainActor({ browserManager.getPath() }) == path {
|
|
603
|
-
Task {
|
|
604
|
-
do {
|
|
605
|
-
try await browserManager.navigate(path)
|
|
606
|
-
} catch {
|
|
607
|
-
handleNavigationError(error, path: path)
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
public func invalidateAllContent() throws {
|
|
614
|
-
onMainActor { browserManager.clearContentCache() }
|
|
615
|
-
|
|
616
|
-
// Tell external controllers (CarPlay) to refresh every displayed template.
|
|
617
|
-
externalContentChangedEmitter.emit(Self.invalidateAllSentinel)
|
|
618
|
-
|
|
619
|
-
// Re-query the tabs source too: a callback source can resolve to new
|
|
620
|
-
// titles after an invalidation (e.g. a locale switch). BrowserManager
|
|
621
|
-
// emits tabsChanged only when the result actually differs, and a failed
|
|
622
|
-
// re-query keeps the current tabs. (Android needs no equivalent — its
|
|
623
|
-
// root re-query already runs queryTabs(); the web stub re-queries tabs on
|
|
624
|
-
// the re-navigation.)
|
|
625
|
-
Task {
|
|
626
|
-
_ = try? await browserManager.queryTabs()
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Re-resolve the JS-facing current path with the cache cleared.
|
|
630
|
-
Task {
|
|
631
|
-
do {
|
|
632
|
-
try await browserManager.refresh()
|
|
633
|
-
} catch {
|
|
634
|
-
handleNavigationError(error, path: onMainActor { browserManager.getPath() })
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
public func setFavorites(favorites: [String]) throws {
|
|
640
|
-
onMainActor { browserManager.setFavorites(favorites) }
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// MARK: - Gate
|
|
644
|
-
|
|
645
|
-
/// The gate's default chrome, if one was set. Served when the resolver
|
|
646
|
-
/// returns a gated decision with no per-request override, and for the static
|
|
647
|
-
/// gate (no resolver). May be nil for a resolver-only gate.
|
|
648
|
-
private(set) var defaultChrome: Gate?
|
|
649
|
-
|
|
650
|
-
/// Whether JS installed a per-request resolver. When false the gate is static
|
|
651
|
-
/// — every request is gated with `defaultChrome` and the JS hop is skipped.
|
|
652
|
-
private(set) var hasResolver = false
|
|
653
|
-
|
|
654
|
-
/// True once `setGate` runs, until `clearGate`. While active, external
|
|
655
|
-
/// surfaces (CarPlay) keep their tabs visible but consult `gateDecision(for:)`
|
|
656
|
-
/// per request, rendering the gate chrome where gated and refusing
|
|
657
|
-
/// external-surface search. Playback, the queue and Now Playing are
|
|
658
|
-
/// unaffected — a gate blocks finding content, never hearing it.
|
|
659
|
-
private(set) var isGateActive = false
|
|
660
|
-
|
|
661
|
-
/// The minimal built-in gate served when a request is gated but neither a
|
|
662
|
-
/// per-request override nor a stored default chrome exists (resolver-only
|
|
663
|
-
/// gate returning `true`).
|
|
664
|
-
static let builtInGate = Gate(title: "Unavailable", message: nil)
|
|
665
|
-
|
|
666
|
-
/// The outcome of a single gate decision: whether to gate, and (if so) the
|
|
667
|
-
/// chrome to render.
|
|
668
|
-
struct GateOutcome {
|
|
669
|
-
let gated: Bool
|
|
670
|
-
let chrome: Gate?
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
public func setGate(gate: Gate?, hasResolver: Bool) throws {
|
|
674
|
-
onMainActor {
|
|
675
|
-
defaultChrome = gate
|
|
676
|
-
self.hasResolver = hasResolver
|
|
677
|
-
isGateActive = true
|
|
678
|
-
gateChangedEmitter.emit(true)
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
public func clearGate() throws {
|
|
683
|
-
onMainActor {
|
|
684
|
-
guard isGateActive else { return }
|
|
685
|
-
defaultChrome = nil
|
|
686
|
-
hasResolver = false
|
|
687
|
-
isGateActive = false
|
|
688
|
-
gateChangedEmitter.emit(false)
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
/// The single choke point for every gate enforcement site. Asks whether
|
|
693
|
-
/// `request` should be gated and, if so, with what chrome (override → stored
|
|
694
|
-
/// default → built-in). When no resolver is installed the gate is static and
|
|
695
|
-
/// resolves without a JS hop.
|
|
696
|
-
///
|
|
697
|
-
/// Resolver errors **fail CLOSED by design**: a thrown/rejected resolver, or a
|
|
698
|
-
/// rejection at either `.await()` layer (bridge tear-down on JS reload, the JS
|
|
699
|
-
/// runtime mid-reload, a serialization error) cannot prove the request is
|
|
700
|
-
/// allowed — and a gate exists to withhold content, so serving on error would
|
|
701
|
-
/// be a content leak (a paywall bypass). On error we gate with the stored
|
|
702
|
-
/// default / built-in chrome so the user still sees a coherent gate page. A
|
|
703
|
-
/// *successful* resolver returning `gated: false` still allows the request.
|
|
704
|
-
@MainActor
|
|
705
|
-
func gateDecision(for request: NativeGateRequest) async -> GateOutcome {
|
|
706
|
-
guard isGateActive else { return GateOutcome(gated: false, chrome: nil) }
|
|
707
|
-
if !hasResolver { return GateOutcome(gated: true, chrome: defaultChrome ?? Self.builtInGate) }
|
|
708
|
-
guard let decision = try? await resolveGate(request).await().await() else {
|
|
709
|
-
// Resolver error: fail CLOSED (see doc comment) — gate with the default /
|
|
710
|
-
// built-in so the gate page still renders rather than leaking content.
|
|
711
|
-
return GateOutcome(gated: true, chrome: defaultChrome ?? Self.builtInGate)
|
|
712
|
-
}
|
|
713
|
-
guard decision.gated else { return GateOutcome(gated: false, chrome: nil) } // explicit allow
|
|
714
|
-
return GateOutcome(gated: true, chrome: decision.gate ?? defaultChrome ?? Self.builtInGate)
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
// MARK: - Car Connection (CarPlay)
|
|
718
|
-
|
|
719
|
-
/// Static because the CarPlay scene can connect before the JS runtime
|
|
720
|
-
/// creates the shared instance (cold start in the car).
|
|
721
|
-
private nonisolated(unsafe) static var carPlayConnected = false
|
|
722
|
-
|
|
723
|
-
/// Called by the CarPlay scene delegate (via `RNABCarPlayController`) on
|
|
724
|
-
/// scene connect/disconnect — not from controller start/stop, which also
|
|
725
|
-
/// cycle on a JS reload and would emit spurious connection changes.
|
|
726
|
-
static func setCarPlayConnected(_ connected: Bool) {
|
|
727
|
-
guard carPlayConnected != connected else { return }
|
|
728
|
-
carPlayConnected = connected
|
|
729
|
-
shared?.onCarConnectedChanged(connected)
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
public func isCarConnected() throws -> Bool {
|
|
733
|
-
HybridAudioBrowser.carPlayConnected
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
public var onCarConnectedChanged: (Bool) -> Void = { _ in } {
|
|
737
|
-
didSet {
|
|
738
|
-
// Immediately notify current state (the scene may have connected before
|
|
739
|
-
// this JS runtime subscribed).
|
|
740
|
-
onCarConnectedChanged(HybridAudioBrowser.carPlayConnected)
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// MARK: - Player Setup
|
|
745
|
-
|
|
746
|
-
public func setupPlayer(options: NativeSetupPlayerOptions) throws -> Promise<Void> {
|
|
747
|
-
Promise.async {
|
|
748
|
-
// Configure the audio session from the iOS setup options (category, mode, route policy, and
|
|
749
|
-
// options like `allowBluetooth` / `allowAirPlay`), but do NOT activate it here. Activating a
|
|
750
|
-
// non-mixable `.playback` session interrupts other apps' audio — so activating at setup
|
|
751
|
-
// pauses whatever the user is listening to (Safari, Spotify, a podcast) the instant our app
|
|
752
|
-
// launches, before they play anything. Setting the category is silent; only activation
|
|
753
|
-
// interrupts. The session is activated lazily when playback actually starts producing output
|
|
754
|
-
// (see `playerDidChangePlayingState`) and re-activated on interruption-end. Best-effort: a
|
|
755
|
-
// category-config failure must never brick setup.
|
|
756
|
-
let session = AVAudioSession.sharedInstance()
|
|
757
|
-
let cfg: (
|
|
758
|
-
category: AVAudioSession.Category,
|
|
759
|
-
mode: AVAudioSession.Mode,
|
|
760
|
-
policy: AVAudioSession.RouteSharingPolicy,
|
|
761
|
-
options: AVAudioSession.CategoryOptions,
|
|
762
|
-
) = options.ios?.resolveAudioSessionConfig()
|
|
763
|
-
?? (category: .playback, mode: .default, policy: .default, options: [])
|
|
764
|
-
// Capture category application once so a media-services reset (which
|
|
765
|
-
// clears the category) can restore the exact same config later.
|
|
766
|
-
let applyCategory: () -> Void = {
|
|
767
|
-
try? session.setCategory(cfg.category, mode: cfg.mode, policy: cfg.policy, options: cfg.options)
|
|
768
|
-
}
|
|
769
|
-
applyCategory()
|
|
770
|
-
|
|
771
|
-
// Create player and configure on main actor
|
|
772
|
-
await MainActor.run { [weak self] in
|
|
773
|
-
guard let self else { return }
|
|
774
|
-
self.applyAudioSessionCategory = applyCategory
|
|
775
|
-
|
|
776
|
-
// A re-setup REPLACES the player, so the outgoing one has to be torn down
|
|
777
|
-
// and un-published first. `TrackPlayer` has no deinit: dropping the
|
|
778
|
-
// reference alone would leave its AVPlayer and MPNowPlayingSession alive.
|
|
779
|
-
// And `playerAndConfiguredBrowser` holds the old player strongly — its
|
|
780
|
-
// `resolve` is a one-shot, so without the reset the gate would keep
|
|
781
|
-
// handing the dead player to the CarPlay controller and Siri intents,
|
|
782
|
-
// which both await it. No-op on first setup.
|
|
783
|
-
player?.destroy()
|
|
784
|
-
playerAndConfiguredBrowser.reset()
|
|
785
|
-
|
|
786
|
-
// Create player with self as callbacks delegate
|
|
787
|
-
player = TrackPlayer(callbacks: self)
|
|
788
|
-
|
|
789
|
-
// Apply buffer duration from options (in ms)
|
|
790
|
-
if let buffer = options.ios?.buffer {
|
|
791
|
-
player?.bufferDuration = buffer
|
|
792
|
-
}
|
|
793
|
-
|
|
794
|
-
// Apply retry configuration (top-level, cross-platform)
|
|
795
|
-
if let retry = options.retry {
|
|
796
|
-
player?.retryConfig = retry
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Wire up network monitor for accelerated retries when connectivity is restored
|
|
800
|
-
player?.networkMonitor = networkMonitor
|
|
801
|
-
|
|
802
|
-
// Central fan-out for connectivity changes (wired once here, idempotent on re-setup, and
|
|
803
|
-
// independent of any JS subscription):
|
|
804
|
-
// - bridge to the JS `onOnlineChanged` event (no-op default until JS subscribes);
|
|
805
|
-
// - re-render now-playing so the formatter's offline/online label updates immediately on a
|
|
806
|
-
// connectivity change, not only on the next playback transition (fixes the stall-entry
|
|
807
|
-
// race where the device goes offline after the buffering line was already rendered);
|
|
808
|
-
// - reconnect a stalled stream when connectivity returns (see TrackPlayer.handleNetworkRestored).
|
|
809
|
-
// The monitor only mutates state on the main thread, so this fires main-isolated.
|
|
810
|
-
networkMonitor.onChanged = { [weak self] isOnline in
|
|
811
|
-
MainActor.assumeIsolated {
|
|
812
|
-
guard let self else { return }
|
|
813
|
-
self.onOnlineChanged(isOnline)
|
|
814
|
-
self.applyNowPlayingMetadata()
|
|
815
|
-
if isOnline { self.player?.handleNetworkRestored() }
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
// Observe audio-session interruptions so another app taking over audio
|
|
820
|
-
// (or a phone call) reflects as a real pause rather than a stuck state.
|
|
821
|
-
setupInterruptionObserver()
|
|
822
|
-
|
|
823
|
-
// Observe media-server resets so a long-running stream recovers instead
|
|
824
|
-
// of going permanently silent.
|
|
825
|
-
setupMediaServicesResetObserver()
|
|
826
|
-
|
|
827
|
-
// Configure media URL resolver
|
|
828
|
-
player?.mediaLoader.mediaUrlResolver = { [weak self] src, track in
|
|
829
|
-
guard let self else {
|
|
830
|
-
return MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
|
|
831
|
-
}
|
|
832
|
-
return await browserManager.resolveMediaUrl(src, track: track)
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Configure artwork URL resolver for Now Playing (with size context). The
|
|
836
|
-
// `nowPlayingArtwork` config runs for EVERY playing track, id or not — an id-less track
|
|
837
|
-
// leaves any `{id}` token unfilled (resolveArtworkUrl warns and the request visibly
|
|
838
|
-
// 404s) rather than skipping the config.
|
|
839
|
-
player?.nowPlayingUpdater.artworkUrlResolver = { [weak self] track, imageContext in
|
|
840
|
-
guard let self else { return nil }
|
|
841
|
-
return await browserManager.resolveArtworkUrl(
|
|
842
|
-
track: track, perRouteConfig: browserManager.config.nowPlayingArtwork,
|
|
843
|
-
imageContext: imageContext,
|
|
844
|
-
)
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
// Emit the JS `onNowPlayingChanged` event when the rendered text lines change. The updater
|
|
848
|
-
// owns the system now-playing write; this shapes the JS metadata (elapsed time / artwork).
|
|
849
|
-
player?.nowPlayingUpdater.onChanged = { [weak self] track, title, artist, album in
|
|
850
|
-
guard let self else { return }
|
|
851
|
-
self.onNowPlayingChanged(
|
|
852
|
-
self.makeNowPlayingMetadata(track: track, title: title, artist: artist, album: album))
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
// Configure sleep timer callback
|
|
856
|
-
player?.sleepTimerManager.onChanged = { [weak self] state in
|
|
857
|
-
self?.onSleepTimerChanged(state)
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
// Now-playing: whether to manage metadata, and the optional formatter that owns the
|
|
861
|
-
// rendered lines.
|
|
862
|
-
nowPlayingMetadataEnabled = options.autoUpdateNowPlayingMetadata ?? true
|
|
863
|
-
nowPlayingMetadataFormatter = options.nowPlayingMetadataFormatter
|
|
864
|
-
// `options.keepSessionAliveOnError` is deliberately ignored here: iOS needs no masking.
|
|
865
|
-
// A terminal error resolves the playback state to *paused*
|
|
866
|
-
// (PlaybackCoordinator.applySideEffects), the now-playing info is retained (never cleared
|
|
867
|
-
// on error), and next/previous stay enabled (gated on queue position, not state). So the
|
|
868
|
-
// session stays controllable through errors by default — the behavior Android achieves via
|
|
869
|
-
// InterceptingPlayer is already the iOS norm.
|
|
870
|
-
|
|
871
|
-
// The bundled runtime options and initial state are part of the atomic launch
|
|
872
|
-
// description. Stage the state first so setup options and earlier pre-setup setter
|
|
873
|
-
// calls resolve by strict last-write-wins, then apply everything to the live player.
|
|
874
|
-
if let bundled = options.options {
|
|
875
|
-
updateOptions(options: bundled)
|
|
876
|
-
} else {
|
|
877
|
-
// updateOptions applies remote commands itself; without bundled options, apply the
|
|
878
|
-
// defaults (play, pause, next, previous, seekTo) here.
|
|
879
|
-
applyRemoteCommands()
|
|
880
|
-
}
|
|
881
|
-
if let mode = options.repeatMode { pendingRepeatMode = mode }
|
|
882
|
-
if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
|
|
883
|
-
applyPendingPlayerState()
|
|
884
|
-
|
|
885
|
-
// Push the resolved event cadence unconditionally. `updateOptions` applies
|
|
886
|
-
// the interval only when it *differs* from the stored one, but the store
|
|
887
|
-
// outlives the player — so on a re-setup with unchanged options the new
|
|
888
|
-
// player would come up with no progress timer at all.
|
|
889
|
-
player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
|
|
890
|
-
player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
|
|
891
|
-
|
|
892
|
-
// Notify listeners that player is ready (e.g., CarPlay)
|
|
893
|
-
playerAndConfiguredBrowser.check()
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
/// Applies initial player state staged before the player existed — from setup options or the
|
|
899
|
-
/// imperative setters called pre-setup. Consumed on apply so a later re-setup doesn't replay
|
|
900
|
-
/// stale state.
|
|
901
|
-
@MainActor
|
|
902
|
-
private func applyPendingPlayerState() {
|
|
903
|
-
guard let player else { return }
|
|
904
|
-
if let mode = pendingRepeatMode {
|
|
905
|
-
player.repeatMode = mode
|
|
906
|
-
pendingRepeatMode = nil
|
|
907
|
-
}
|
|
908
|
-
if let intent = pendingPlayWhenReady {
|
|
909
|
-
player.playWhenReady = intent
|
|
910
|
-
pendingPlayWhenReady = nil
|
|
911
|
-
}
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
public func updateOptions(options: NativeUpdateOptions) {
|
|
915
|
-
onMainActor {
|
|
916
|
-
let previousInterval = playerOptions.progressUpdateEventInterval
|
|
917
|
-
|
|
918
|
-
// Update stored options
|
|
919
|
-
playerOptions.update(from: options)
|
|
920
|
-
|
|
921
|
-
// Propagate the favorite flag to the browser so it can hydrate
|
|
922
|
-
// row hearts (the `favorite` capability is the single favoriting switch).
|
|
923
|
-
browserManager.setFavoriteEnabled(playerOptions.capabilities.favoriteEnabled)
|
|
924
|
-
|
|
925
|
-
// Apply remote commands to player
|
|
926
|
-
applyRemoteCommands()
|
|
927
|
-
|
|
928
|
-
// Apply progress update interval if changed
|
|
929
|
-
if playerOptions.progressUpdateEventInterval != previousInterval {
|
|
930
|
-
player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
onOptionsChanged(playerOptions.toOptions())
|
|
934
|
-
playerOptionsChangedEmitter.emit(())
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
|
|
938
|
-
/// Converts capabilities to remote commands and applies them to the player
|
|
939
|
-
@MainActor
|
|
940
|
-
private func applyRemoteCommands() {
|
|
941
|
-
guard let player else { return }
|
|
942
|
-
|
|
943
|
-
let remoteCommands = playerOptions.capabilities.buildRemoteCommands(
|
|
944
|
-
forwardJumpInterval: NSNumber(value: playerOptions.forwardJumpInterval),
|
|
945
|
-
backwardJumpInterval: NSNumber(value: playerOptions.backwardJumpInterval),
|
|
946
|
-
playbackRates: playerOptions.playbackRates,
|
|
947
|
-
)
|
|
948
|
-
|
|
949
|
-
player.remoteCommands = remoteCommands
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
public func getOptions() throws -> Options {
|
|
953
|
-
onMainActor { playerOptions.toOptions() }
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
/// Stored so a re-setup can re-apply it: unlike the other runtime options this
|
|
957
|
-
/// one has no home in `playerOptions`, and the player it was pushed to does not
|
|
958
|
-
/// survive `setupPlayer`.
|
|
959
|
-
@MainActor private var playbackIntervalEnabled = false
|
|
960
|
-
|
|
961
|
-
public func setPlaybackIntervalEnabled(enabled: Bool) {
|
|
962
|
-
onMainActor {
|
|
963
|
-
playbackIntervalEnabled = enabled
|
|
964
|
-
player?.setPlaybackIntervalEnabled(enabled)
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
// MARK: - Playback Control
|
|
969
|
-
|
|
970
|
-
public func load(track: Track) throws {
|
|
971
|
-
try onMainActor {
|
|
972
|
-
guard let player else {
|
|
973
|
-
throw NSError(domain: "AudioBrowser", code: 1, userInfo: [NSLocalizedDescriptionKey: "Player not initialized"])
|
|
974
|
-
}
|
|
975
|
-
player.load(track)
|
|
976
|
-
}
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
public func reset() throws {
|
|
980
|
-
onMainActor { player?.clear() }
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
public func play() throws {
|
|
984
|
-
onMainActor { player?.play() }
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
public func pause() throws {
|
|
988
|
-
onMainActor { player?.pause() }
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
public func togglePlayback() throws {
|
|
992
|
-
onMainActor { player?.togglePlayback() }
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
public func stop() throws {
|
|
996
|
-
onMainActor { player?.stop() }
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
public func setPlayWhenReady(playWhenReady: Bool) throws {
|
|
1000
|
-
onMainActor {
|
|
1001
|
-
// Pre-setup this stages the intent for the player to come up with — never a no-op.
|
|
1002
|
-
if let player { player.playWhenReady = playWhenReady }
|
|
1003
|
-
else { pendingPlayWhenReady = playWhenReady }
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
public func getPlayWhenReady() throws -> Bool {
|
|
1008
|
-
onMainActor { player?.playWhenReady ?? pendingPlayWhenReady ?? false }
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
public func seekTo(position: Double) throws {
|
|
1012
|
-
onMainActor { player?.seekTo(position) }
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
public func seekBy(offset: Double) throws {
|
|
1016
|
-
onMainActor { player?.seekBy(offset) }
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
public func seekToLiveEdge() throws {
|
|
1020
|
-
onMainActor { player?.seekToLiveEdge() }
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
public func setVolume(level: Double) throws {
|
|
1024
|
-
onMainActor { player?.volume = Float(level) }
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
public func getVolume() throws -> Double {
|
|
1028
|
-
onMainActor { Double(player?.volume ?? 1.0) }
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
public func setRate(rate: Double) throws {
|
|
1032
|
-
onMainActor { player?.rate = Float(rate) }
|
|
1033
|
-
}
|
|
1034
|
-
|
|
1035
|
-
public func getRate() throws -> Double {
|
|
1036
|
-
onMainActor { Double(player?.rate ?? 1.0) }
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
public func getProgress() throws -> Progress {
|
|
1040
|
-
onMainActor {
|
|
1041
|
-
Progress(
|
|
1042
|
-
position: player?.currentTime ?? 0,
|
|
1043
|
-
duration: player?.duration ?? 0,
|
|
1044
|
-
buffered: player?.bufferedPosition ?? 0,
|
|
1045
|
-
)
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
public func getPlayback() throws -> Playback {
|
|
1050
|
-
onMainActor { player?.getPlayback() ?? Playback(state: .none, error: nil) }
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
public func getPlayingState() throws -> PlayingState {
|
|
1054
|
-
onMainActor {
|
|
1055
|
-
player?.playingStateManager.toPlayingState() ?? PlayingState(playing: false, buffering: false)
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
public func getRepeatMode() throws -> RepeatMode {
|
|
1060
|
-
onMainActor { player?.repeatMode ?? pendingRepeatMode ?? .off }
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
public func setRepeatMode(mode: RepeatMode) throws {
|
|
1064
|
-
onMainActor {
|
|
1065
|
-
// Pre-setup this stages the mode for the player to come up with — never a no-op.
|
|
1066
|
-
if let player { player.repeatMode = mode }
|
|
1067
|
-
else { pendingRepeatMode = mode }
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
public func getShuffleEnabled() throws -> Bool {
|
|
1072
|
-
onMainActor { player?.shuffleEnabled ?? false }
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
public func setShuffleEnabled(enabled: Bool) throws {
|
|
1076
|
-
onMainActor { player?.shuffleEnabled = enabled }
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
public func getPlaybackError() throws -> PlaybackError? {
|
|
1080
|
-
onMainActor { player?.coordinator.nitroPlaybackError() }
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
public func retry() throws {
|
|
1084
|
-
// Re-resolve rather than replay the cached URL: short-lived URLs/tokens
|
|
1085
|
-
// may have expired since the failure (matches the internal retry path).
|
|
1086
|
-
onMainActor { player?.reloadResolving(startFromCurrentTime: true) }
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// MARK: - Sleep Timer
|
|
1090
|
-
|
|
1091
|
-
public func getSleepTimer() throws -> SleepTimer {
|
|
1092
|
-
onMainActor {
|
|
1093
|
-
if let state = player?.sleepTimerManager.get() {
|
|
1094
|
-
return state
|
|
1095
|
-
}
|
|
1096
|
-
return .first(NullType.null)
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
public func setSleepTimer(seconds: Double, fadeDuration: Double?) throws {
|
|
1101
|
-
onMainActor {
|
|
1102
|
-
player?.sleepTimerManager.set(seconds: seconds, fadeDuration: fadeDuration)
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
public func setSleepTimerToEndOfTrack() throws {
|
|
1107
|
-
onMainActor {
|
|
1108
|
-
player?.sleepTimerManager.setToEndOfTrack()
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
|
|
1112
|
-
public func clearSleepTimer() throws -> Bool {
|
|
1113
|
-
onMainActor {
|
|
1114
|
-
player?.sleepTimerManager.clear() ?? false
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
|
|
1118
|
-
// MARK: - Queue Management
|
|
1119
|
-
|
|
1120
|
-
public func add(tracks: [Track], insertBeforeIndex: Double?) throws {
|
|
1121
|
-
try onMainActor {
|
|
1122
|
-
guard let player else { return }
|
|
1123
|
-
if let index = insertBeforeIndex {
|
|
1124
|
-
try player.add(tracks, at: Int(index))
|
|
1125
|
-
} else {
|
|
1126
|
-
player.add(tracks)
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
public func move(fromIndex: Double, toIndex: Double) throws {
|
|
1132
|
-
try onMainActor {
|
|
1133
|
-
try player?.move(fromIndex: Int(fromIndex), toIndex: Int(toIndex))
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
public func remove(indexes: [Double]) throws {
|
|
1138
|
-
try onMainActor {
|
|
1139
|
-
guard let player else { return }
|
|
1140
|
-
// Remove in reverse order to maintain index validity
|
|
1141
|
-
for index in indexes.sorted().reversed() {
|
|
1142
|
-
try player.remove(Int(index))
|
|
1143
|
-
}
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
public func removeUpcomingTracks() throws {
|
|
1148
|
-
onMainActor { player?.removeUpcomingTracks() }
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
public func skip(index: Double, initialPosition: Double?) throws {
|
|
1152
|
-
try onMainActor {
|
|
1153
|
-
try player?.skipTo(Int(index))
|
|
1154
|
-
if let position = initialPosition {
|
|
1155
|
-
player?.seekTo(position)
|
|
1156
|
-
}
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
public func skipToNext(initialPosition: Double?) throws {
|
|
1161
|
-
onMainActor {
|
|
1162
|
-
player?.next()
|
|
1163
|
-
if let position = initialPosition {
|
|
1164
|
-
player?.seekTo(position)
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
public func skipToPrevious(initialPosition: Double?) throws {
|
|
1170
|
-
onMainActor {
|
|
1171
|
-
player?.previous()
|
|
1172
|
-
if let position = initialPosition {
|
|
1173
|
-
player?.seekTo(position)
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
public func setActiveTrackFavorited(favorited: Bool) throws {
|
|
1179
|
-
try onMainActor {
|
|
1180
|
-
guard let track = player?.currentTrack, let identity = track.identity else { return }
|
|
1181
|
-
guard let index = player?.currentIndex, index >= 0 else { return }
|
|
1182
|
-
// Optimistically reflect in the authoritative favorite set so the
|
|
1183
|
-
// now-playing heart (hydrated from it in getActiveTrack) flips
|
|
1184
|
-
// immediately; the consumer reconciles to its canonical identities on
|
|
1185
|
-
// the next setFavorites.
|
|
1186
|
-
browserManager.setFavorited(identity: identity, favorited: favorited)
|
|
1187
|
-
let updatedTrack = track.copying(favorited: favorited)
|
|
1188
|
-
// Hydration never overrides a caller-set `favorited`, so the queued
|
|
1189
|
-
// track must itself carry the latest explicit value (as Android's tag
|
|
1190
|
-
// swap and web's replaceTrack do).
|
|
1191
|
-
try player?.replace(index, updatedTrack)
|
|
1192
|
-
// The ONLY emit: a favorite toggle is an in-place mutation of the active
|
|
1193
|
-
// track, not a transition — onPlaybackActiveTrackChanged stays
|
|
1194
|
-
// transition-only, and the useActiveTrack / useQueue hooks subscribe to
|
|
1195
|
-
// onFavoriteChanged themselves. Native surfaces (CarPlay heart) observe
|
|
1196
|
-
// favoriteChangedEmitter / onFavoritesChanged directly.
|
|
1197
|
-
favoriteChangedEmitter.emit(FavoriteChangedEvent(track: updatedTrack, favorited: favorited))
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
/// Whether the active (now-playing) track is favorited, per the authoritative
|
|
1202
|
-
/// favorite set (via `getActiveTrack`'s hydration).
|
|
1203
|
-
func isActiveTrackFavorited() -> Bool {
|
|
1204
|
-
(try? getActiveTrack())?.favorited ?? false
|
|
1205
|
-
}
|
|
1206
|
-
|
|
1207
|
-
public func toggleActiveTrackFavorited() throws {
|
|
1208
|
-
try? setActiveTrackFavorited(favorited: !isActiveTrackFavorited())
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
public func setQueue(tracks: [Track], startIndex: Double?, startPosition: Double?) throws {
|
|
1212
|
-
onMainActor {
|
|
1213
|
-
guard let player else { return }
|
|
1214
|
-
player.setQueue(
|
|
1215
|
-
tracks,
|
|
1216
|
-
initialIndex: startIndex.map { Int($0) } ?? 0,
|
|
1217
|
-
startPositionMs: startPosition.map { $0 * 1000 },
|
|
1218
|
-
)
|
|
1219
|
-
}
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
public func getQueue() throws -> [Track] {
|
|
1223
|
-
onMainActor { player?.tracks ?? [] }
|
|
1224
|
-
}
|
|
1225
|
-
|
|
1226
|
-
public func getTrack(index: Double) throws -> Track? {
|
|
1227
|
-
onMainActor {
|
|
1228
|
-
guard let tracks = player?.tracks else { return nil }
|
|
1229
|
-
let i = Int(index)
|
|
1230
|
-
guard i >= 0, i < tracks.count else { return nil }
|
|
1231
|
-
return tracks[i]
|
|
1232
|
-
}
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
public func getActiveTrackIndex() throws -> Double? {
|
|
1236
|
-
onMainActor {
|
|
1237
|
-
guard let index = player?.currentIndex, index >= 0 else { return nil }
|
|
1238
|
-
return Double(index)
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
public func getActiveTrack() throws -> Track? {
|
|
1243
|
-
// Hydrate `favorited` from the authoritative favorite set — the now-playing
|
|
1244
|
-
// track is loaded outside the browse cache, so its own flag isn't otherwise
|
|
1245
|
-
// kept in sync as favorites change. This is the single source of truth for
|
|
1246
|
-
// "is the active track favorited" (now-playing heart, toggle direction, JS).
|
|
1247
|
-
onMainActor { player?.currentTrack.map(browserManager.hydrateFavorite) }
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
// MARK: - Now Playing
|
|
1251
|
-
|
|
1252
|
-
public func updateNowPlaying(update: NowPlayingUpdate?) throws {
|
|
1253
|
-
onMainActor {
|
|
1254
|
-
nowPlayingOverride = update
|
|
1255
|
-
applyNowPlayingMetadata()
|
|
1256
|
-
}
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
/// Transient now-playing fields (e.g. feedback for a refused remote
|
|
1260
|
-
/// command). Outranks the formatter and the override while active, so live
|
|
1261
|
-
/// metadata can't stomp it mid-flash. Reverted by a NATIVE timer — JS
|
|
1262
|
-
/// timers pause with a backgrounded host on Android, and the lock screen
|
|
1263
|
-
/// is exactly the backgrounded case — and cleared early on track change.
|
|
1264
|
-
@MainActor private var nowPlayingFlash: NowPlayingUpdate?
|
|
1265
|
-
@MainActor private var nowPlayingFlashRevert: DispatchWorkItem?
|
|
1266
|
-
|
|
1267
|
-
/// Pending debounced audio-session release (see `playerShouldReleaseSession`).
|
|
1268
|
-
@MainActor private var sessionReleaseWork: DispatchWorkItem?
|
|
1269
|
-
|
|
1270
|
-
public func flashNowPlaying(update: NowPlayingUpdate, durationMs: Double) throws {
|
|
1271
|
-
onMainActor {
|
|
1272
|
-
nowPlayingFlashRevert?.cancel()
|
|
1273
|
-
nowPlayingFlash = update
|
|
1274
|
-
let revert = DispatchWorkItem { [weak self] in
|
|
1275
|
-
guard let self else { return }
|
|
1276
|
-
self.nowPlayingFlash = nil
|
|
1277
|
-
self.nowPlayingFlashRevert = nil
|
|
1278
|
-
self.applyNowPlayingMetadata()
|
|
1279
|
-
}
|
|
1280
|
-
nowPlayingFlashRevert = revert
|
|
1281
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + durationMs / 1000, execute: revert)
|
|
1282
|
-
applyNowPlayingMetadata()
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
public func clearNowPlayingFlash() throws {
|
|
1287
|
-
onMainActor {
|
|
1288
|
-
guard nowPlayingFlash != nil else { return }
|
|
1289
|
-
cancelNowPlayingFlash()
|
|
1290
|
-
applyNowPlayingMetadata()
|
|
1291
|
-
}
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
@MainActor
|
|
1295
|
-
private func cancelNowPlayingFlash() {
|
|
1296
|
-
nowPlayingFlashRevert?.cancel()
|
|
1297
|
-
nowPlayingFlashRevert = nil
|
|
1298
|
-
nowPlayingFlash = nil
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
public func getNowPlaying() throws -> NowPlayingMetadata? {
|
|
1302
|
-
onMainActor {
|
|
1303
|
-
guard let track = player?.currentTrack else { return nil }
|
|
1304
|
-
let flash = nowPlayingFlash
|
|
1305
|
-
let override = nowPlayingOverride
|
|
1306
|
-
return makeNowPlayingMetadata(
|
|
1307
|
-
track: track,
|
|
1308
|
-
title: flash?.title ?? override?.title ?? track.title,
|
|
1309
|
-
artist: flash?.artist ?? override?.artist ?? track.artist,
|
|
1310
|
-
album: flash?.album ?? override?.album ?? track.album,
|
|
1311
|
-
)
|
|
1312
|
-
}
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
/// Returns the artwork URI suitable for JS consumption.
|
|
1316
|
-
/// Prefers the resolved artworkSource URI (e.g. file:// for rendered SF Symbols).
|
|
1317
|
-
/// Falls back to the raw artwork string only if it's not an SF Symbol (which JS can't load).
|
|
1318
|
-
private func nowPlayingArtwork(for track: Track) -> String? {
|
|
1319
|
-
if let uri = track.artworkSource?.uri { return uri }
|
|
1320
|
-
guard let artwork = track.artwork?.url else { return nil }
|
|
1321
|
-
return SFSymbolRenderer.isSFSymbol(artwork) ? nil : artwork
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
/// Builds the JS-facing now-playing metadata for the given rendered text lines.
|
|
1325
|
-
@MainActor
|
|
1326
|
-
private func makeNowPlayingMetadata(
|
|
1327
|
-
track: Track, title: String, artist: String?, album: String?,
|
|
1328
|
-
) -> NowPlayingMetadata {
|
|
1329
|
-
NowPlayingMetadata(
|
|
1330
|
-
elapsedTime: player?.currentTime,
|
|
1331
|
-
title: title,
|
|
1332
|
-
album: album,
|
|
1333
|
-
artist: artist,
|
|
1334
|
-
duration: track.duration,
|
|
1335
|
-
artwork: nowPlayingArtwork(for: track),
|
|
1336
|
-
description: track.description,
|
|
1337
|
-
mediaId: track.src ?? track.path,
|
|
1338
|
-
genre: track.genre,
|
|
1339
|
-
)
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
/// Re-renders the now-playing surface via `NowPlayingUpdater`, handing it the current playback
|
|
1343
|
-
/// signals, override, and formatter. Called on every track / metadata / playback-state change.
|
|
1344
|
-
@MainActor
|
|
1345
|
-
private func applyNowPlayingMetadata() {
|
|
1346
|
-
guard let player, let track = player.currentTrack else { return }
|
|
1347
|
-
// Classify a stall by connectivity so the formatter can show "No internet connection" vs
|
|
1348
|
-
// "Reconnecting…" without a separate read. nil while not stalled.
|
|
1349
|
-
let stalled: StallReason? =
|
|
1350
|
-
player.isStalled ? (networkMonitor.isOnline ? .buffering : .offline) : nil
|
|
1351
|
-
player.nowPlayingUpdater.render(
|
|
1352
|
-
track: track,
|
|
1353
|
-
timedMetadata: latestTimedMetadata,
|
|
1354
|
-
playWhenReady: player.playWhenReady,
|
|
1355
|
-
stalled: stalled,
|
|
1356
|
-
error: player.coordinator.nitroPlaybackError(),
|
|
1357
|
-
flash: nowPlayingFlash,
|
|
1358
|
-
override: nowPlayingMetadataEnabled ? nowPlayingOverride : nil,
|
|
1359
|
-
formatter: nowPlayingMetadataEnabled ? nowPlayingMetadataFormatter : nil,
|
|
1360
|
-
)
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
// MARK: - Network
|
|
1364
|
-
|
|
1365
|
-
public func getOnline() throws -> Bool {
|
|
1366
|
-
// No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
|
|
1367
|
-
// cheap read on the JS thread instead of blocking on the main queue.
|
|
1368
|
-
networkMonitor.isOnline
|
|
1369
|
-
}
|
|
1370
|
-
|
|
1371
|
-
// MARK: - System Volume
|
|
1372
|
-
|
|
1373
|
-
/// Sets up KVO observer for system volume changes
|
|
1374
|
-
private func setupVolumeObserver() {
|
|
1375
|
-
// Remove existing observation if any
|
|
1376
|
-
volumeObservation?.invalidate()
|
|
1377
|
-
|
|
1378
|
-
let audioSession = AVAudioSession.sharedInstance()
|
|
1379
|
-
volumeObservation = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] _, change in
|
|
1380
|
-
guard let newVolume = change.newValue else { return }
|
|
1381
|
-
self?.onSystemVolumeChanged(Double(newVolume))
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
public func getSystemVolume() throws -> Double {
|
|
1386
|
-
Double(AVAudioSession.sharedInstance().outputVolume)
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
public func setSystemVolume(volume _: Double) throws {
|
|
1390
|
-
// iOS doesn't provide a public API to set system volume programmatically.
|
|
1391
|
-
// Users must adjust volume via hardware buttons or Control Center.
|
|
1392
|
-
logger.debug("setSystemVolume is not supported on iOS - volume must be adjusted via hardware buttons or Control Center")
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
// MARK: - External Audio Output
|
|
1396
|
-
|
|
1397
|
-
/// Observes audio-session interruptions (phone calls, or another app such as
|
|
1398
|
-
/// Music/Spotify starting playback). iOS pauses our AVPlayer underneath us but
|
|
1399
|
-
/// doesn't update our playback state, so without this the UI stays stuck on
|
|
1400
|
-
/// "playing". On `.began` we reflect a real pause; on `.ended` we resume if we
|
|
1401
|
-
/// were playing and the system says we should.
|
|
1402
|
-
private func setupInterruptionObserver() {
|
|
1403
|
-
if let observer = interruptionObserver {
|
|
1404
|
-
NotificationCenter.default.removeObserver(observer)
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
interruptionObserver = NotificationCenter.default.addObserver(
|
|
1408
|
-
forName: AVAudioSession.interruptionNotification,
|
|
1409
|
-
object: nil,
|
|
1410
|
-
queue: .main,
|
|
1411
|
-
) { [weak self] notification in
|
|
1412
|
-
self?.handleAudioSessionInterruption(notification)
|
|
1413
|
-
}
|
|
1414
|
-
}
|
|
1415
|
-
|
|
1416
|
-
private func handleAudioSessionInterruption(_ notification: Notification) {
|
|
1417
|
-
guard
|
|
1418
|
-
let info = notification.userInfo,
|
|
1419
|
-
let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
1420
|
-
let type = AVAudioSession.InterruptionType(rawValue: typeValue)
|
|
1421
|
-
else { return }
|
|
1422
|
-
|
|
1423
|
-
switch type {
|
|
1424
|
-
case .began:
|
|
1425
|
-
// iOS 17+ fires a `.began` (with no matching `.ended`) when an output
|
|
1426
|
-
// route such as headphones disappears. Reflecting a pause here would
|
|
1427
|
-
// strand resume-intent state with nothing to clear it, and the
|
|
1428
|
-
// route-change observer already handles the output change — so ignore it.
|
|
1429
|
-
if #available(iOS 17.0, *),
|
|
1430
|
-
let reasonValue = info[AVAudioSessionInterruptionReasonKey] as? UInt,
|
|
1431
|
-
AVAudioSession.InterruptionReason(rawValue: reasonValue) == .routeDisconnected
|
|
1432
|
-
{
|
|
1433
|
-
return
|
|
1434
|
-
}
|
|
1435
|
-
onMainActor { player?.handleInterruptionBegan() }
|
|
1436
|
-
case .ended:
|
|
1437
|
-
let shouldResume = (info[AVAudioSessionInterruptionOptionKey] as? UInt)
|
|
1438
|
-
.map { AVAudioSession.InterruptionOptions(rawValue: $0).contains(.shouldResume) }
|
|
1439
|
-
?? false
|
|
1440
|
-
onMainActor {
|
|
1441
|
-
// Reactivate the possibly-deactivated session before resuming — but
|
|
1442
|
-
// only when a resume will happen: activating while staying paused
|
|
1443
|
-
// grabs a non-mixable session with nothing to release it.
|
|
1444
|
-
if shouldResume, player?.willResumeAfterInterruption == true {
|
|
1445
|
-
try? AVAudioSession.sharedInstance().setActive(true)
|
|
1446
|
-
}
|
|
1447
|
-
player?.handleInterruptionEnded(shouldResume: shouldResume)
|
|
1448
|
-
}
|
|
1449
|
-
@unknown default:
|
|
1450
|
-
break
|
|
1451
|
-
}
|
|
1452
|
-
}
|
|
1453
|
-
|
|
1454
|
-
/// Sets up observer for audio route changes
|
|
1455
|
-
private func setupRouteChangeObserver() {
|
|
1456
|
-
if let observer = routeChangeObserver {
|
|
1457
|
-
NotificationCenter.default.removeObserver(observer)
|
|
1458
|
-
}
|
|
1459
|
-
|
|
1460
|
-
routeChangeObserver = NotificationCenter.default.addObserver(
|
|
1461
|
-
forName: AVAudioSession.routeChangeNotification,
|
|
1462
|
-
object: nil,
|
|
1463
|
-
queue: .main,
|
|
1464
|
-
) { [weak self] notification in
|
|
1465
|
-
guard let self else { return }
|
|
1466
|
-
// The previous output disappeared (headphones unplugged, a Bluetooth
|
|
1467
|
-
// speaker powered off). Pause rather than abruptly resume out of the
|
|
1468
|
-
// built-in speaker — the platform convention every media app follows.
|
|
1469
|
-
// A deliberate pause (clears play intent), not an interruption: the user
|
|
1470
|
-
// restarts playback themselves once they want it.
|
|
1471
|
-
if let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
1472
|
-
AVAudioSession.RouteChangeReason(rawValue: reasonValue) == .oldDeviceUnavailable
|
|
1473
|
-
{
|
|
1474
|
-
// Also cancels a pending interruption auto-resume: unplugging during
|
|
1475
|
-
// a call must not blast the built-in speaker when the call ends.
|
|
1476
|
-
self.onMainActor { self.player?.handleRouteDisconnected() }
|
|
1477
|
-
}
|
|
1478
|
-
if let output = self.getCurrentOutput() {
|
|
1479
|
-
self.onOutputChanged(output)
|
|
1480
|
-
}
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
/// Observes audio-server (`mediaserverd`) resets. When it fires, the session
|
|
1485
|
-
/// category is cleared and every AVPlayer/session handle is invalid — a
|
|
1486
|
-
/// long-running stream would otherwise go permanently silent until the app
|
|
1487
|
-
/// restarts.
|
|
1488
|
-
private func setupMediaServicesResetObserver() {
|
|
1489
|
-
if let observer = mediaServicesResetObserver {
|
|
1490
|
-
NotificationCenter.default.removeObserver(observer)
|
|
1491
|
-
}
|
|
1492
|
-
mediaServicesResetObserver = NotificationCenter.default.addObserver(
|
|
1493
|
-
forName: AVAudioSession.mediaServicesWereResetNotification,
|
|
1494
|
-
object: nil,
|
|
1495
|
-
queue: .main,
|
|
1496
|
-
) { [weak self] _ in
|
|
1497
|
-
guard let self else { return }
|
|
1498
|
-
// Re-apply our category (the reset cleared it), then have the player
|
|
1499
|
-
// recreate itself and reconnect the current stream.
|
|
1500
|
-
self.onMainActor {
|
|
1501
|
-
self.applyAudioSessionCategory()
|
|
1502
|
-
self.player?.handleMediaServicesReset()
|
|
1503
|
-
}
|
|
1504
|
-
}
|
|
1505
|
-
}
|
|
1506
|
-
|
|
1507
|
-
/// Gets the current audio output info
|
|
1508
|
-
private func getCurrentOutput() -> Output? {
|
|
1509
|
-
let session = AVAudioSession.sharedInstance()
|
|
1510
|
-
guard let output = session.currentRoute.outputs.first else { return nil }
|
|
1511
|
-
|
|
1512
|
-
let (outputType, external): (OutputType, Bool) = switch output.portType {
|
|
1513
|
-
case .builtInSpeaker:
|
|
1514
|
-
(.speaker, false)
|
|
1515
|
-
case .builtInReceiver:
|
|
1516
|
-
(.receiver, false)
|
|
1517
|
-
case .airPlay:
|
|
1518
|
-
(.airplay, true)
|
|
1519
|
-
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE:
|
|
1520
|
-
(.bluetooth, true)
|
|
1521
|
-
case .headphones:
|
|
1522
|
-
(.headphones, true)
|
|
1523
|
-
case .carAudio:
|
|
1524
|
-
(.car, true)
|
|
1525
|
-
case .HDMI:
|
|
1526
|
-
(.hdmi, true)
|
|
1527
|
-
case .usbAudio:
|
|
1528
|
-
(.usb, true)
|
|
1529
|
-
default:
|
|
1530
|
-
(.other, true)
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
return Output(type: outputType, name: output.portName, external: external)
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
public func getOutput() throws -> Output? {
|
|
1537
|
-
getCurrentOutput()
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
public func supportsOutputSwitcher() throws -> Bool {
|
|
1541
|
-
// iOS can always present the system route picker (AVRoutePickerView).
|
|
1542
|
-
true
|
|
1543
|
-
}
|
|
1544
|
-
|
|
1545
|
-
public func openOutputPicker() throws {
|
|
1546
|
-
DispatchQueue.main.async { [weak self] in
|
|
1547
|
-
guard let self else { return }
|
|
1548
|
-
guard let windowScene = UIApplication.shared.activeWindowScene,
|
|
1549
|
-
let window = windowScene.windows.first(where: { $0.isKeyWindow }),
|
|
1550
|
-
var topController = window.rootViewController
|
|
1551
|
-
else {
|
|
1552
|
-
self.logger.error("openOutputPicker: no active window scene / root view controller")
|
|
1553
|
-
return
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
// Walk up to the topmost presented view controller
|
|
1557
|
-
while let presented = topController.presentedViewController {
|
|
1558
|
-
topController = presented
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
// AVRoutePickerView has no public API to present its picker
|
|
1562
|
-
// programmatically, so we add it to the hierarchy and tap its internal
|
|
1563
|
-
// button. Two requirements that are easy to get wrong:
|
|
1564
|
-
// 1. The view must be *visible* to the system (a `isHidden` source view
|
|
1565
|
-
// suppresses the presentation), so we keep it transparent instead.
|
|
1566
|
-
// 2. The internal button can be nested below the top-level subviews on
|
|
1567
|
-
// modern iOS, so we search recursively rather than only direct children.
|
|
1568
|
-
let routePicker = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
|
|
1569
|
-
routePicker.alpha = 0.01
|
|
1570
|
-
topController.view.addSubview(routePicker)
|
|
1571
|
-
|
|
1572
|
-
let button = Self.firstButton(in: routePicker)
|
|
1573
|
-
self.logger.notice(
|
|
1574
|
-
"openOutputPicker: hierarchy=\(Self.describeHierarchy(routePicker), privacy: .public), buttonFound=\(button != nil, privacy: .public)",
|
|
1575
|
-
)
|
|
1576
|
-
|
|
1577
|
-
if let button {
|
|
1578
|
-
button.sendActions(for: .touchUpInside)
|
|
1579
|
-
} else {
|
|
1580
|
-
self.logger.error("openOutputPicker: no UIButton found inside AVRoutePickerView")
|
|
1581
|
-
}
|
|
1582
|
-
|
|
1583
|
-
// Keep the source view in the hierarchy long enough for the picker to
|
|
1584
|
-
// present (on iPad it anchors a popover to this view).
|
|
1585
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
1586
|
-
routePicker.removeFromSuperview()
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
}
|
|
1590
|
-
|
|
1591
|
-
/// Depth-first search for the first `UIButton` inside a view tree.
|
|
1592
|
-
private static func firstButton(in view: UIView) -> UIButton? {
|
|
1593
|
-
if let button = view as? UIButton { return button }
|
|
1594
|
-
for subview in view.subviews {
|
|
1595
|
-
if let button = firstButton(in: subview) { return button }
|
|
1596
|
-
}
|
|
1597
|
-
return nil
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
/// Compact description of a view tree, e.g. `AVRoutePickerView[UIButton[UIImageView]]`.
|
|
1601
|
-
private static func describeHierarchy(_ view: UIView) -> String {
|
|
1602
|
-
let name = String(describing: type(of: view))
|
|
1603
|
-
let children = view.subviews.map(describeHierarchy).joined(separator: ", ")
|
|
1604
|
-
return children.isEmpty ? name : "\(name)[\(children)]"
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
// MARK: - Equalizer (unsupported on iOS)
|
|
1608
|
-
|
|
1609
|
-
public func getEqualizerSettings() throws -> EqualizerSettings? {
|
|
1610
|
-
// No-op: equalizer unsupported on iOS
|
|
1611
|
-
nil
|
|
1612
|
-
}
|
|
1613
|
-
|
|
1614
|
-
public func setEqualizerEnabled(enabled _: Bool) throws {
|
|
1615
|
-
// No-op: equalizer unsupported on iOS
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
public func setEqualizerPreset(preset _: String) throws {
|
|
1619
|
-
// No-op: equalizer unsupported on iOS
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
public func setEqualizerLevels(levels _: [Double]) throws {
|
|
1623
|
-
// No-op: equalizer unsupported on iOS
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
// MARK: - Battery (Android-only, stub implementations)
|
|
1627
|
-
|
|
1628
|
-
public func getBatteryWarningPending() throws -> Bool {
|
|
1629
|
-
false
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
public func getBatteryOptimizationStatus() throws -> BatteryOptimizationStatus {
|
|
1633
|
-
// iOS doesn't have battery optimization restrictions like Android
|
|
1634
|
-
.unrestricted
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
public func dismissBatteryWarning() throws {
|
|
1638
|
-
// No-op on iOS
|
|
1639
|
-
}
|
|
1640
|
-
|
|
1641
|
-
public func openBatterySettings() throws {
|
|
1642
|
-
// No-op on iOS
|
|
1643
|
-
}
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
// MARK: - TrackPlayerCallbacks
|
|
1647
|
-
|
|
1648
|
-
extension HybridAudioBrowser: TrackPlayerCallbacks {
|
|
1649
|
-
public func playerDidChangePlayback(_ playback: Playback) {
|
|
1650
|
-
onPlaybackChanged(playback)
|
|
1651
|
-
// Re-render so the formatter reflects the new state — notably the error line on a failure (the
|
|
1652
|
-
// coordinator sets playbackError before this fires). Redundant updates are deduped on publish.
|
|
1653
|
-
applyNowPlayingMetadata()
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
public func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent) {
|
|
1657
|
-
// Clear now playing override + flash + live metadata when track changes
|
|
1658
|
-
// (matches Kotlin behavior; a flash must not carry over to a new track)
|
|
1659
|
-
nowPlayingOverride = nil
|
|
1660
|
-
cancelNowPlayingFlash()
|
|
1661
|
-
latestTimedMetadata = nil
|
|
1662
|
-
// Hydrate the active track's `favorited` from the authoritative set so JS
|
|
1663
|
-
// consumers (useActiveTrack) match getActiveTrack()'s hydrated value — the
|
|
1664
|
-
// coordinator emits the raw queue track, whose flag isn't kept in sync.
|
|
1665
|
-
let hydrated = PlaybackActiveTrackChangedEvent(
|
|
1666
|
-
lastIndex: event.lastIndex,
|
|
1667
|
-
lastTrack: event.lastTrack,
|
|
1668
|
-
lastPosition: event.lastPosition,
|
|
1669
|
-
index: event.index,
|
|
1670
|
-
track: event.track.map(browserManager.hydrateFavorite),
|
|
1671
|
-
)
|
|
1672
|
-
activeTrackChangedEmitter.emit(hydrated)
|
|
1673
|
-
// Also notify now playing changed when track changes
|
|
1674
|
-
applyNowPlayingMetadata()
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
public func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent) {
|
|
1678
|
-
onPlaybackProgressUpdated(event)
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
public func playerDidFirePlaybackInterval() {
|
|
1682
|
-
onPlaybackInterval()
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
public func playerDidChangePlayWhenReady(_ playWhenReady: Bool) {
|
|
1686
|
-
// Intent to play again is the earliest cancel signal for a pending session release.
|
|
1687
|
-
if playWhenReady { cancelSessionRelease() }
|
|
1688
|
-
onPlaybackPlayWhenReadyChanged(PlaybackPlayWhenReadyChangedEvent(playWhenReady: playWhenReady))
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
public func playerDidChangePlayingState(_ state: PlayingState) {
|
|
1692
|
-
onPlaybackPlayingState(state)
|
|
1693
|
-
// Lazily activate the audio session the moment playback actually produces — or is buffering to
|
|
1694
|
-
// produce — output. This (not setup) is where we grab the session, so launching the app never
|
|
1695
|
-
// interrupts other apps' audio; the interruption only happens when the user truly starts
|
|
1696
|
-
// playback, which is correct. Keyed off play *state*, not `playWhenReady`, because the intent
|
|
1697
|
-
// can be set at setup (before any track) while output only begins here. Idempotent:
|
|
1698
|
-
// `setActive(true)` is a no-op when the session is already active.
|
|
1699
|
-
if state.playing || state.buffering {
|
|
1700
|
-
try? AVAudioSession.sharedInstance().setActive(true)
|
|
1701
|
-
// The now-playing session's activation request at link time predates this
|
|
1702
|
-
// (the audio session was still inactive, and the system may have declined);
|
|
1703
|
-
// re-request now so the surface can appear even if playback never produces
|
|
1704
|
-
// audio (first-ever load failing on a dead stream).
|
|
1705
|
-
player?.nowPlayingInfoController.reactivateSessionIfNeeded()
|
|
1706
|
-
// Output resumed — cancel any pending release armed by an earlier stop.
|
|
1707
|
-
cancelSessionRelease()
|
|
1708
|
-
}
|
|
1709
|
-
// Re-render so the formatter reacts to a stall starting or recovering (`stalled`) — a buffering
|
|
1710
|
-
// flag change that may not transition the coordinator state.
|
|
1711
|
-
applyNowPlayingMetadata()
|
|
1712
|
-
}
|
|
1713
|
-
|
|
1714
|
-
public func playerShouldReleaseSession() {
|
|
1715
|
-
// Release the audio session so other apps' audio (Spotify, a podcast) can resume — debounced,
|
|
1716
|
-
// because iOS dislikes deactivating while our audio is still trailing off, and a quick
|
|
1717
|
-
// pause→resume shouldn't thrash the session. The coordinator only requests this on a genuine
|
|
1718
|
-
// stop (not an interruption, not mid-retry); a re-activation cancels it (see
|
|
1719
|
-
// `playerDidChangePlayWhenReady` and the activation above). Mirrors the now-playing flash timer.
|
|
1720
|
-
sessionReleaseWork?.cancel()
|
|
1721
|
-
let release = DispatchWorkItem { [weak self] in
|
|
1722
|
-
guard let self, self.player?.playWhenReady == false else { return }
|
|
1723
|
-
self.sessionReleaseWork = nil
|
|
1724
|
-
// `.notifyOthersOnDeactivation` signals other apps they may resume; without it the session
|
|
1725
|
-
// is released but nobody is told, so the user's audio would not come back.
|
|
1726
|
-
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
|
1727
|
-
}
|
|
1728
|
-
sessionReleaseWork = release
|
|
1729
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 3, execute: release)
|
|
1730
|
-
}
|
|
1731
|
-
|
|
1732
|
-
@MainActor
|
|
1733
|
-
private func cancelSessionRelease() {
|
|
1734
|
-
sessionReleaseWork?.cancel()
|
|
1735
|
-
sessionReleaseWork = nil
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
public func playerDidEndQueue(_ event: PlaybackQueueEndedEvent) {
|
|
1739
|
-
onPlaybackQueueEnded(event)
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
public func playerDidChangeQueue(_ tracks: [Track]) {
|
|
1743
|
-
queueChangedEmitter.emit(tracks)
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
public func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent) {
|
|
1747
|
-
repeatModeChangedEmitter.emit(event)
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
public func playerDidChangeShuffleEnabled(_ enabled: Bool) {
|
|
1751
|
-
shuffleChangedEmitter.emit(enabled)
|
|
1752
|
-
onPlaybackShuffleModeChanged(enabled)
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
public func playerDidError(_ event: PlaybackErrorEvent) {
|
|
1756
|
-
onPlaybackError(event)
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
public func playerDidReceiveCommonMetadata(_ items: [AVMetadataItem]) {
|
|
1760
|
-
let metadata = TrackMetadata.from(items: items)
|
|
1761
|
-
onTrackMetadata(metadata)
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
public func playerDidReceiveTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
|
|
1765
|
-
for group in groups {
|
|
1766
|
-
if let metadata = TimedMetadata.from(items: group.items, icyCharset: player?.currentTrack?.icyCharset) {
|
|
1767
|
-
latestTimedMetadata = metadata
|
|
1768
|
-
onTimedMetadata(metadata)
|
|
1769
|
-
// Re-render the now-playing so the formatter can reflect the live song.
|
|
1770
|
-
applyNowPlayingMetadata()
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
public func playerDidReceiveChapterMetadata(_ groups: [AVTimedMetadataGroup]) {
|
|
1776
|
-
let chapters = ChapterMetadata.from(groups: groups)
|
|
1777
|
-
onChapterMetadata(chapters)
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
public func playerDidCompleteSeek(position _: Double, didFinish _: Bool) {
|
|
1781
|
-
// Not exposed to JS currently
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
public func playerDidUpdateDuration(_: Double) {
|
|
1785
|
-
// Not exposed to JS currently
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
public func remotePlay() {
|
|
1789
|
-
if let handler = handleRemotePlay {
|
|
1790
|
-
handler()
|
|
1791
|
-
} else {
|
|
1792
|
-
// Default behavior: play the player
|
|
1793
|
-
onMainActor { player?.play() }
|
|
1794
|
-
}
|
|
1795
|
-
onRemotePlay()
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
public func remotePause() {
|
|
1799
|
-
if let handler = handleRemotePause {
|
|
1800
|
-
handler()
|
|
1801
|
-
} else {
|
|
1802
|
-
// Default behavior: pause the player
|
|
1803
|
-
onMainActor { player?.pause() }
|
|
1804
|
-
}
|
|
1805
|
-
onRemotePause()
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
public func remoteStop() {
|
|
1809
|
-
if let handler = handleRemoteStop {
|
|
1810
|
-
handler()
|
|
1811
|
-
} else {
|
|
1812
|
-
// Default behavior: stop the player
|
|
1813
|
-
onMainActor { player?.stop() }
|
|
1814
|
-
}
|
|
1815
|
-
onRemoteStop()
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
public func remotePlayPause() {
|
|
1819
|
-
// Toggle based on current state
|
|
1820
|
-
let isPlaying = onMainActor { player?.playWhenReady == true }
|
|
1821
|
-
if isPlaying {
|
|
1822
|
-
remotePause()
|
|
1823
|
-
} else {
|
|
1824
|
-
remotePlay()
|
|
1825
|
-
}
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
public func remoteNext() {
|
|
1829
|
-
if let handler = handleRemoteNext {
|
|
1830
|
-
handler()
|
|
1831
|
-
} else {
|
|
1832
|
-
// Default behavior: skip to next track
|
|
1833
|
-
onMainActor { player?.next() }
|
|
1834
|
-
}
|
|
1835
|
-
onRemoteNext()
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
public func remotePrevious() {
|
|
1839
|
-
if let handler = handleRemotePrevious {
|
|
1840
|
-
handler()
|
|
1841
|
-
} else {
|
|
1842
|
-
// Default behavior: skip to previous track
|
|
1843
|
-
onMainActor { player?.previous() }
|
|
1844
|
-
}
|
|
1845
|
-
onRemotePrevious()
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
public func remoteJumpForward(interval: Double) {
|
|
1849
|
-
let event = RemoteJumpForwardEvent(interval: interval)
|
|
1850
|
-
if let handler = handleRemoteJumpForward {
|
|
1851
|
-
handler(event)
|
|
1852
|
-
} else {
|
|
1853
|
-
// Default behavior: seek forward by interval
|
|
1854
|
-
onMainActor { player?.seekBy(interval) }
|
|
1855
|
-
}
|
|
1856
|
-
onRemoteJumpForward(event)
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
public func remoteJumpBackward(interval: Double) {
|
|
1860
|
-
let event = RemoteJumpBackwardEvent(interval: interval)
|
|
1861
|
-
if let handler = handleRemoteJumpBackward {
|
|
1862
|
-
handler(event)
|
|
1863
|
-
} else {
|
|
1864
|
-
// Default behavior: seek backward by interval
|
|
1865
|
-
onMainActor { player?.seekBy(-interval) }
|
|
1866
|
-
}
|
|
1867
|
-
onRemoteJumpBackward(event)
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
public func remoteSeek(position: Double) {
|
|
1871
|
-
logger.info("remoteSeek called with position: \(position)")
|
|
1872
|
-
let event = RemoteSeekEvent(position: position)
|
|
1873
|
-
if let handler = handleRemoteSeek {
|
|
1874
|
-
handler(event)
|
|
1875
|
-
} else {
|
|
1876
|
-
// Default behavior: seek to position
|
|
1877
|
-
logger.info("remoteSeek: calling player.seekTo(\(position))")
|
|
1878
|
-
onMainActor { player?.seekTo(position) }
|
|
1879
|
-
}
|
|
1880
|
-
onRemoteSeek(event)
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
public func remoteChangePlaybackPosition(position: Double) {
|
|
1884
|
-
remoteSeek(position: position)
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
public func remotePlayId(id: String, index: Int?) {
|
|
1888
|
-
let event = RemotePlayIdEvent(id: id, index: index.map { Double($0) })
|
|
1889
|
-
if let handler = handleRemotePlayId {
|
|
1890
|
-
handler(event)
|
|
1891
|
-
} else {
|
|
1892
|
-
onRemotePlayId(event)
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
public func remotePlaySearch(query: String) {
|
|
1897
|
-
let event = RemotePlaySearchEvent(query: query)
|
|
1898
|
-
if let handler = handleRemotePlaySearch {
|
|
1899
|
-
handler(event)
|
|
1900
|
-
} else {
|
|
1901
|
-
onRemotePlaySearch(event)
|
|
1902
|
-
}
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
/// Static so the Siri intent handler can call it before the shared instance
|
|
1906
|
-
/// exists: it waits on the `playerAndConfiguredBrowser` gate, which resolves
|
|
1907
|
-
/// once RN boots. Uses the resolved `browser`, never `self`.
|
|
1908
|
-
static func handlePlayMediaIntent(criteria: MediaIntentCriteria, completion: @escaping @Sendable (Bool) -> Void) {
|
|
1909
|
-
Task { @MainActor in
|
|
1910
|
-
guard let (browser, player) = await playerAndConfiguredBrowser.wait(timeout: .seconds(8)) else {
|
|
1911
|
-
Logger(subsystem: "com.audiobrowser", category: "AudioBrowser")
|
|
1912
|
-
.error("handlePlayMediaIntent: browser/player not ready within budget")
|
|
1913
|
-
completion(false)
|
|
1914
|
-
return
|
|
1915
|
-
}
|
|
1916
|
-
// No-criteria intent ("play «app»") → resume. The gate must NOT block
|
|
1917
|
-
// this: the gate blocks *finding* content, never *hearing* the active or
|
|
1918
|
-
// persisted track. The gate is checked below, for the search branch only.
|
|
1919
|
-
if criteria.isResume {
|
|
1920
|
-
if player.currentTrack != nil {
|
|
1921
|
-
browser.logger.info("resume: warm — playing current track")
|
|
1922
|
-
player.play()
|
|
1923
|
-
browser.showNowPlayingRequestedEmitter.emit(())
|
|
1924
|
-
completion(true)
|
|
1925
|
-
} else if let state = browser.playbackStateStore.load() {
|
|
1926
|
-
let track = state.track.toNitro()
|
|
1927
|
-
// The persisted track can carry `disabled` — it became unavailable
|
|
1928
|
-
// after it was saved. A disabled track never plays, resumption
|
|
1929
|
-
// included (Track.disabled).
|
|
1930
|
-
if track.disabled == true {
|
|
1931
|
-
browser.logger.info("resume: persisted track is disabled — refusing")
|
|
1932
|
-
completion(false)
|
|
1933
|
-
return
|
|
1934
|
-
}
|
|
1935
|
-
browser.logger.info("resume: cold — restoring persisted track")
|
|
1936
|
-
// Repeat / shuffle / speed come back before the queue is built — see
|
|
1937
|
-
// `restorePlaybackSettings(from:)` for why that ordering matters.
|
|
1938
|
-
player.restorePlaybackSettings(from: state)
|
|
1939
|
-
let startMs = (track.live == true) ? nil : state.positionMs
|
|
1940
|
-
// Match Android resume: re-expand the full queue from the track's contextual
|
|
1941
|
-
// path (parent container → siblings + selected index). Fall back to the single
|
|
1942
|
-
// track when the path isn't contextual or expansion fails.
|
|
1943
|
-
if let path = state.track.path,
|
|
1944
|
-
let expanded = try? await browser.browserManager.expandQueueFromContextualPath(path)
|
|
1945
|
-
{
|
|
1946
|
-
player.setQueue(expanded.tracks, initialIndex: expanded.selectedIndex, startPositionMs: startMs, playWhenReady: true)
|
|
1947
|
-
} else {
|
|
1948
|
-
player.setQueue([track], initialIndex: 0, startPositionMs: startMs, playWhenReady: true)
|
|
1949
|
-
}
|
|
1950
|
-
browser.showNowPlayingRequestedEmitter.emit(())
|
|
1951
|
-
completion(true)
|
|
1952
|
-
} else {
|
|
1953
|
-
browser.logger.error("resume: nothing playing and nothing persisted → no-op")
|
|
1954
|
-
completion(false) // nothing playing and nothing persisted
|
|
1955
|
-
}
|
|
1956
|
-
return
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
// Assemble the Nitro SearchParams here (MainActor) from the criteria's
|
|
1960
|
-
// Sendable fields, so the structured mode/genre/… reach the request like
|
|
1961
|
-
// Android. Today the API still text-searches `q`; the rest is forward-compat.
|
|
1962
|
-
let params = SearchParams(
|
|
1963
|
-
mode: criteria.searchMode.flatMap { SearchMode(fromString: $0) },
|
|
1964
|
-
query: criteria.query,
|
|
1965
|
-
genre: criteria.genre,
|
|
1966
|
-
artist: criteria.artist,
|
|
1967
|
-
album: criteria.album,
|
|
1968
|
-
title: criteria.title,
|
|
1969
|
-
playlist: criteria.playlist,
|
|
1970
|
-
// .currentlyPlaying can't reach here — isResume routed it to resume.
|
|
1971
|
-
reference: criteria.reference == .my ? .my : .unknown,
|
|
1972
|
-
)
|
|
1973
|
-
|
|
1974
|
-
// Search is *finding* new content — refused when the gate decides this
|
|
1975
|
-
// request is gated (resume above is unaffected: the gate never blocks
|
|
1976
|
-
// hearing, and the gate check stays after the resume check).
|
|
1977
|
-
let outcome = await browser.gateDecision(
|
|
1978
|
-
for: NativeGateRequest(reason: .search, path: nil, search: params),
|
|
1979
|
-
)
|
|
1980
|
-
guard !outcome.gated else {
|
|
1981
|
-
browser.logger.info("handlePlayMediaIntent: search refused — gated")
|
|
1982
|
-
browser.onGate(GateEvent(reason: .search))
|
|
1983
|
-
completion(false)
|
|
1984
|
-
return
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
do {
|
|
1988
|
-
guard let tracks = try await browser.browserManager.searchPlayable(params) else {
|
|
1989
|
-
completion(false)
|
|
1990
|
-
return
|
|
1991
|
-
}
|
|
1992
|
-
player.setQueue(tracks, initialIndex: 0, playWhenReady: true)
|
|
1993
|
-
browser.showNowPlayingRequestedEmitter.emit(())
|
|
1994
|
-
completion(true)
|
|
1995
|
-
} catch {
|
|
1996
|
-
browser.logger.error("handlePlayMediaIntent failed: \(error.localizedDescription)")
|
|
1997
|
-
completion(false)
|
|
1998
|
-
}
|
|
1999
|
-
}
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
public func remoteChangeRepeatMode(mode: RepeatMode) {
|
|
2003
|
-
// Apply the repeat mode change from CarPlay/lock screen
|
|
2004
|
-
try? setRepeatMode(mode: mode)
|
|
2005
|
-
}
|
|
2006
|
-
|
|
2007
|
-
public func remoteChangeShuffleMode(enabled: Bool) {
|
|
2008
|
-
// Apply the shuffle mode change from CarPlay/lock screen
|
|
2009
|
-
try? setShuffleEnabled(enabled: enabled)
|
|
2010
|
-
}
|
|
2011
|
-
|
|
2012
|
-
public func remoteChangePlaybackRate(rate: Float) {
|
|
2013
|
-
// Apply the playback rate change from CarPlay/lock screen
|
|
2014
|
-
onMainActor { player?.rate = rate }
|
|
2015
|
-
}
|
|
2016
|
-
|
|
2017
|
-
func playerDidChangeOptions(_: PlayerUpdateOptions) {
|
|
2018
|
-
// TODO: Convert to Options type
|
|
2019
|
-
}
|
|
2020
407
|
}
|
|
2021
408
|
|
|
2022
409
|
// MARK: - Autolinking Alias
|