react-native-audio-browser 0.2.0-next.33 → 0.2.0-next.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ios/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
- package/ios/CarPlay/CarPlaySession.swift +3 -3
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- package/ios/Extension/Capability+RemoteCommand.swift +22 -69
- package/ios/Extension/Track+AVPlayer.swift +0 -16
- package/ios/HybridAudioBrowser+Browser.swift +16 -17
- package/ios/HybridAudioBrowser+Options.swift +27 -66
- package/ios/HybridAudioBrowser+Player.swift +4 -60
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
- package/ios/HybridAudioBrowser.swift +36 -99
- package/ios/Model/NitroTypeStubs.swift +15 -0
- package/ios/Model/PlayerUpdateOptions.swift +2 -0
- package/ios/Model/RemoteCommand.swift +6 -78
- package/ios/Player/NowPlayingUpdater.swift +13 -7
- package/ios/Player/Player.swift +26 -10
- package/ios/Player/PlayerCallbacks.swift +1 -4
- package/ios/Player/PlayerEngine.swift +17 -0
- package/ios/Player/RetryLoop.swift +5 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
- package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +95 -17
- package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
- package/ios/Tests/Support/TestTypes.swift +44 -3
- package/ios/TrackSelector.swift +34 -52
- package/ios/Util/Emitter.swift +0 -14
- package/ios/Util/NetworkMonitor.swift +8 -25
- package/ios/Util/OutputPicker.swift +48 -0
- package/package.json +1 -1
- package/ios/Model/SourceType.swift +0 -6
- package/ios/Tests/RemoteCommandTests.swift +0 -65
|
@@ -10,8 +10,6 @@ import os.log
|
|
|
10
10
|
extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
11
11
|
public func playerDidChangePlayback(_ playback: Playback) {
|
|
12
12
|
onPlaybackChanged(playback)
|
|
13
|
-
// The Player sets the error before this fires, so the error line renders.
|
|
14
|
-
engine?.nowPlayingUpdater.render()
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
public func playerDidChangeActiveTrack(_ event: PlaybackActiveTrackChangedEvent) {
|
|
@@ -25,8 +23,8 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
25
23
|
index: event.index,
|
|
26
24
|
track: event.track.map(browserManager.hydrateFavorite),
|
|
27
25
|
)
|
|
26
|
+
onPlaybackActiveTrackChanged(hydrated)
|
|
28
27
|
activeTrackChangedEmitter.emit(hydrated)
|
|
29
|
-
engine?.nowPlayingUpdater.trackChanged()
|
|
30
28
|
}
|
|
31
29
|
|
|
32
30
|
public func playerDidUpdateProgress(_ event: PlaybackProgressUpdatedEvent) {
|
|
@@ -38,32 +36,11 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
38
36
|
}
|
|
39
37
|
|
|
40
38
|
public func playerDidChangePlayWhenReady(_ playWhenReady: Bool) {
|
|
41
|
-
// Intent to play again is the earliest cancel signal for a pending session release.
|
|
42
|
-
if playWhenReady { audioSession.cancelRelease() }
|
|
43
39
|
onPlaybackPlayWhenReadyChanged(PlaybackPlayWhenReadyChangedEvent(playWhenReady: playWhenReady))
|
|
44
40
|
}
|
|
45
41
|
|
|
46
42
|
public func playerDidChangePlayingState(_ state: PlayingState) {
|
|
47
43
|
onPlaybackPlayingState(state)
|
|
48
|
-
// Lazily activate the audio session the moment playback actually produces — or is buffering to
|
|
49
|
-
// produce — output. This (not setup) is where we grab the session, so launching the app never
|
|
50
|
-
// interrupts other apps' audio; the interruption only happens when the user truly starts
|
|
51
|
-
// playback, which is correct. Keyed off play *state*, not `playWhenReady`, because the intent
|
|
52
|
-
// can be set at setup (before any track) while output only begins here. Idempotent:
|
|
53
|
-
// `setActive(true)` is a no-op when the session is already active.
|
|
54
|
-
if state.playing || state.buffering {
|
|
55
|
-
// The now-playing session's activation request at link time predates this
|
|
56
|
-
// (the audio session was still inactive, and the system may have declined);
|
|
57
|
-
// re-request once the audio session is active, so the surface can appear even if
|
|
58
|
-
// playback never produces audio (first-ever load failing on a dead stream).
|
|
59
|
-
audioSession.activate { [weak self] in self?.engine?.reactivateNowPlayingSession() }
|
|
60
|
-
}
|
|
61
|
-
// A stall starting or recovering may not change the Player state.
|
|
62
|
-
engine?.nowPlayingUpdater.render()
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
public func playerShouldReleaseSession() {
|
|
66
|
-
audioSession.scheduleRelease()
|
|
67
44
|
}
|
|
68
45
|
|
|
69
46
|
public func playerDidEndQueue(_ event: PlaybackQueueEndedEvent) {
|
|
@@ -71,16 +48,18 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
71
48
|
}
|
|
72
49
|
|
|
73
50
|
public func playerDidChangeQueue(_ tracks: [Track]) {
|
|
51
|
+
onPlaybackQueueChanged(tracks)
|
|
74
52
|
queueChangedEmitter.emit(tracks)
|
|
75
53
|
}
|
|
76
54
|
|
|
77
55
|
public func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent) {
|
|
56
|
+
onPlaybackRepeatModeChanged(event)
|
|
78
57
|
repeatModeChangedEmitter.emit(event)
|
|
79
58
|
}
|
|
80
59
|
|
|
81
60
|
public func playerDidChangeShuffleEnabled(_ enabled: Bool) {
|
|
82
|
-
shuffleChangedEmitter.emit(enabled)
|
|
83
61
|
onPlaybackShuffleModeChanged(enabled)
|
|
62
|
+
shuffleChangedEmitter.emit(enabled)
|
|
84
63
|
}
|
|
85
64
|
|
|
86
65
|
public func playerDidError(_ event: PlaybackErrorEvent) {
|
|
@@ -100,13 +79,8 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
100
79
|
onTrackMetadata(metadata)
|
|
101
80
|
}
|
|
102
81
|
|
|
103
|
-
public func playerDidReceiveTimedMetadata(_
|
|
104
|
-
|
|
105
|
-
if let metadata = TimedMetadata.from(items: group.items, icyCharset: player?.currentTrack?.icyCharset) {
|
|
106
|
-
onTimedMetadata(metadata)
|
|
107
|
-
engine?.nowPlayingUpdater.receivedTimedMetadata(metadata)
|
|
108
|
-
}
|
|
109
|
-
}
|
|
82
|
+
public func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata) {
|
|
83
|
+
onTimedMetadata(metadata)
|
|
110
84
|
}
|
|
111
85
|
|
|
112
86
|
public func playerDidReceiveChapterMetadata(_ groups: [AVTimedMetadataGroup]) {
|
|
@@ -126,10 +100,6 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
126
100
|
dispatchRemote(handler: handleRemoteStop, report: onRemoteStop) { player?.stop() }
|
|
127
101
|
}
|
|
128
102
|
|
|
129
|
-
public func remotePlayPause() {
|
|
130
|
-
if player?.playWhenReady == true { remotePause() } else { remotePlay() }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
103
|
public func remoteNext() {
|
|
134
104
|
dispatchRemote(handler: handleRemoteNext, report: onRemoteNext) { player?.next() }
|
|
135
105
|
}
|
|
@@ -174,12 +144,10 @@ extension HybridAudioBrowser: PlayerCallbacks, AVPlayerEngineCallbacks {
|
|
|
174
144
|
}
|
|
175
145
|
|
|
176
146
|
public func remoteChangeRepeatMode(mode: RepeatMode) {
|
|
177
|
-
// Apply the repeat mode change from CarPlay/lock screen
|
|
178
147
|
try? setRepeatMode(mode: mode)
|
|
179
148
|
}
|
|
180
149
|
|
|
181
150
|
public func remoteChangeShuffleMode(enabled: Bool) {
|
|
182
|
-
// Apply the shuffle mode change from CarPlay/lock screen
|
|
183
151
|
try? setShuffleEnabled(enabled: enabled)
|
|
184
152
|
}
|
|
185
153
|
|
|
@@ -8,7 +8,7 @@ import os.log
|
|
|
8
8
|
@MainActor let playerAndConfiguredBrowser = ReadyValue<(HybridAudioBrowser, Player)> {
|
|
9
9
|
guard let browser = HybridAudioBrowser.shared,
|
|
10
10
|
browser.browserManager.isConfigured,
|
|
11
|
-
let player = browser.
|
|
11
|
+
let player = browser.player else { return nil }
|
|
12
12
|
return (browser, player)
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -34,14 +34,10 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
34
34
|
/// silent.
|
|
35
35
|
static let instanceChangedEmitter = Emitter<Void>()
|
|
36
36
|
|
|
37
|
-
///
|
|
38
|
-
///
|
|
39
|
-
///
|
|
40
|
-
///
|
|
41
|
-
/// stale. `init` of the replacement instance drops them too, but only after
|
|
42
|
-
/// the new runtime is up; this observer closes the window in between.
|
|
43
|
-
/// Observed by name so the library needs no React-Core dependency (and
|
|
44
|
-
/// stays inert under SPM tests, where nothing posts it).
|
|
37
|
+
/// React Native posts this notification when a JS reload is triggered, before the old runtime
|
|
38
|
+
/// is destroyed: the earliest sign this instance's JS callbacks go stale. The replacement's
|
|
39
|
+
/// `init` drops them too, but only once the new runtime is up. Observed by name, so the library
|
|
40
|
+
/// needs no React-Core dependency.
|
|
45
41
|
@MainActor private static let reloadObserver: NSObjectProtocol = NotificationCenter.default.addObserver(
|
|
46
42
|
forName: Notification.Name("RCTTriggerReloadCommandNotification"),
|
|
47
43
|
object: nil,
|
|
@@ -71,29 +67,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
71
67
|
@MainActor var pendingRepeatMode: RepeatMode?
|
|
72
68
|
@MainActor let playerOptions = PlayerUpdateOptions()
|
|
73
69
|
|
|
74
|
-
/// Configured playback rates for the playback-rate capability (for CarPlay rate cycling)
|
|
75
|
-
@MainActor var playbackRates: [Double] { playerOptions.playbackRates }
|
|
76
|
-
@MainActor var carPlayUpNextButton: Bool { playerOptions.carPlayUpNextButton }
|
|
77
|
-
@MainActor var carPlayNowPlayingButtons: [CarPlayNowPlayingButton] {
|
|
78
|
-
playerOptions.carPlayNowPlayingButtons
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
@MainActor var carPlayPlayingIndicatorLocation: CarPlayPlayingIndicatorLocation {
|
|
82
|
-
playerOptions.carPlayPlayingIndicatorLocation
|
|
83
|
-
}
|
|
84
|
-
|
|
85
70
|
@MainActor lazy var navigationError = NavigationErrorState(
|
|
86
|
-
onErrorChanged: { [weak self] in self?.
|
|
71
|
+
onErrorChanged: { [weak self] in self?.onNavigationError(NavigationErrorEvent(error: $0)) },
|
|
87
72
|
onFormattedChanged: { [weak self] in self?.onFormattedNavigationError($0) },
|
|
88
73
|
)
|
|
89
74
|
|
|
90
|
-
// MARK: -
|
|
75
|
+
// MARK: - Emitters for Native Listeners
|
|
91
76
|
|
|
92
|
-
/// Emitters allow multiple listeners for each event type, avoiding callback hijacking
|
|
93
77
|
public let favoriteChangedEmitter = Emitter<FavoriteChangedEvent>()
|
|
94
78
|
public let activeTrackChangedEmitter = Emitter<PlaybackActiveTrackChangedEvent>()
|
|
95
79
|
public let queueChangedEmitter = Emitter<[Track]>()
|
|
96
|
-
public let navigationErrorEmitter = Emitter<NavigationErrorEvent>()
|
|
97
80
|
public let repeatModeChangedEmitter = Emitter<RepeatModeChangedEvent>()
|
|
98
81
|
public let shuffleChangedEmitter = Emitter<Bool>()
|
|
99
82
|
public let externalContentChangedEmitter = Emitter<String>()
|
|
@@ -111,17 +94,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
111
94
|
|
|
112
95
|
/// Runs a closure on MainActor synchronously. Safe for non-Sendable return types
|
|
113
96
|
/// because DispatchQueue.main.sync blocks the caller — no concurrent access window.
|
|
114
|
-
func onMainActor<T>(_ work: @MainActor () -> T) -> T {
|
|
115
|
-
if Thread.isMainThread {
|
|
116
|
-
MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
|
|
117
|
-
} else {
|
|
118
|
-
DispatchQueue.main.sync {
|
|
119
|
-
MainActor.assumeIsolated { UncheckedSendableBox(value: work()) }.value
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
func onMainActor<T>(_ work: @MainActor () throws -> T) throws -> T {
|
|
97
|
+
func onMainActor<T>(_ work: @MainActor () throws -> T) rethrows -> T {
|
|
125
98
|
if Thread.isMainThread {
|
|
126
99
|
try MainActor.assumeIsolated { try UncheckedSendableBox(value: work()) }.value
|
|
127
100
|
} else {
|
|
@@ -159,14 +132,11 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
159
132
|
let configuredPath = configuration.path
|
|
160
133
|
onMainActor { browserManager.config = BrowserConfig(from: configuration) }
|
|
161
134
|
|
|
162
|
-
// Query tabs and navigate to initial path after config is set (matches Kotlin behavior)
|
|
163
135
|
Task { @MainActor in
|
|
164
|
-
// Notify waiting listeners (e.g., CarPlay cold start)
|
|
165
136
|
playerAndConfiguredBrowser.check()
|
|
166
137
|
}
|
|
167
138
|
Task {
|
|
168
139
|
let tabs = try? await browserManager.queryTabs()
|
|
169
|
-
// Navigate to configured path, first tab, or "/"
|
|
170
140
|
navigate(configuredPath ?? tabs?.first?.path ?? "/", clearError: true)
|
|
171
141
|
}
|
|
172
142
|
}
|
|
@@ -258,9 +228,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
258
228
|
public var onNowPlayingChanged: (NowPlayingMetadata) -> Void = { _ in }
|
|
259
229
|
public var onOnlineChanged: (Bool) -> Void = { _ in } {
|
|
260
230
|
didSet {
|
|
261
|
-
//
|
|
262
|
-
// subscription and can also drive the now-playing re-render + player reconnect). Here we only
|
|
263
|
-
// push the current state to a newly-attached subscriber.
|
|
231
|
+
// A new subscriber hears the current state at once.
|
|
264
232
|
onOnlineChanged(networkMonitor.isOnline)
|
|
265
233
|
}
|
|
266
234
|
}
|
|
@@ -280,11 +248,6 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
280
248
|
}
|
|
281
249
|
}
|
|
282
250
|
|
|
283
|
-
/// Stored so a re-setup can re-apply it: unlike the other runtime options this
|
|
284
|
-
/// one has no home in `playerOptions`, and the player it was pushed to does not
|
|
285
|
-
/// survive `setupPlayer`.
|
|
286
|
-
@MainActor var playbackIntervalEnabled = false
|
|
287
|
-
|
|
288
251
|
// MARK: - Initialization
|
|
289
252
|
|
|
290
253
|
override public init() {
|
|
@@ -302,6 +265,15 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
302
265
|
HybridAudioBrowser.shared = self
|
|
303
266
|
audioSession.outputChanged.addListener { [weak self] in self?.onOutputChanged($0) }
|
|
304
267
|
audioSession.volumeChanged.addListener { [weak self] in self?.onSystemVolumeChanged($0) }
|
|
268
|
+
// From the first setup on, whether or not JS subscribes: JS, then the player.
|
|
269
|
+
networkMonitor.changed.addListener { [weak self] isOnline in
|
|
270
|
+
guard let self else { return }
|
|
271
|
+
MainActor.assumeIsolated {
|
|
272
|
+
guard let player = self.player else { return }
|
|
273
|
+
self.onOnlineChanged(isOnline)
|
|
274
|
+
player.networkStatusDidChange(isOnline: isOnline)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
305
277
|
browseGate.resolve = { [weak self] request in
|
|
306
278
|
guard let self else { throw CancellationError() }
|
|
307
279
|
return try await resolveGate(request).await().await()
|
|
@@ -312,24 +284,18 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
312
284
|
// controller is subscribed to a dead instance's emitters.
|
|
313
285
|
HybridAudioBrowser.instanceChangedEmitter.emit(())
|
|
314
286
|
}
|
|
315
|
-
|
|
287
|
+
forwardBrowserEventsToJS()
|
|
316
288
|
}
|
|
317
289
|
|
|
318
290
|
/// Drops every JS-provided callback that native code can invoke on its own
|
|
319
291
|
/// initiative (browse resolvers, `handleTrackLoad`, `resolveAlbumPath`,
|
|
320
292
|
/// `formatNavigationError`, `resolveGate`, the now-playing formatter).
|
|
321
293
|
///
|
|
322
|
-
/// Called on the
|
|
323
|
-
///
|
|
324
|
-
///
|
|
325
|
-
///
|
|
326
|
-
///
|
|
327
|
-
/// working off the data-only config with built-in fallbacks; `resolveGate`
|
|
328
|
-
/// reverts to the fail-closed default it documents for exactly this window.
|
|
329
|
-
/// One-shot: on a normal reload both triggers hit the same dying instance
|
|
330
|
-
/// (the reload notification, then the replacement's `init`), and the second
|
|
331
|
-
/// strip would re-fire the config-setter cascade — another `configChanged`
|
|
332
|
-
/// → CarPlay now-playing button rebuild — for nothing.
|
|
294
|
+
/// Called on the previous instance when a new one takes over: invoking a callback of a dead
|
|
295
|
+
/// runtime aborts the process (Nitro throws across a `noexcept` boundary). Native code still
|
|
296
|
+
/// bound to this instance (CarPlay mid-reload) keeps working off the data-only config;
|
|
297
|
+
/// `resolveGate` reverts to its fail-closed default. One-shot: both triggers reach the same
|
|
298
|
+
/// dying instance, and a second strip would re-run the config setter's cascade.
|
|
333
299
|
@MainActor private var didDropJSCallbacks = false
|
|
334
300
|
|
|
335
301
|
@MainActor
|
|
@@ -348,61 +314,32 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
|
|
|
348
314
|
}
|
|
349
315
|
|
|
350
316
|
deinit {
|
|
351
|
-
// Safety net:
|
|
317
|
+
// Safety net: the AVPlayer must not outlive this instance. `self` can't reach the main
|
|
318
|
+
// actor from here, so the teardown takes what it releases.
|
|
352
319
|
let player = self.player, engine = self.engine
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
player?.clear()
|
|
356
|
-
engine?.destroy()
|
|
357
|
-
}
|
|
358
|
-
} else {
|
|
359
|
-
DispatchQueue.main.async {
|
|
360
|
-
MainActor.assumeIsolated {
|
|
361
|
-
player?.clear()
|
|
362
|
-
engine?.destroy()
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/// Returns the player, if setup has been called.
|
|
369
|
-
func getPlayer() -> Player? {
|
|
370
|
-
player
|
|
320
|
+
let tearDown: @Sendable () -> Void = { MainActor.assumeIsolated { Self.destroy(player, engine) } }
|
|
321
|
+
if Thread.isMainThread { tearDown() } else { DispatchQueue.main.async(execute: tearDown) }
|
|
371
322
|
}
|
|
372
323
|
|
|
373
324
|
/// Stops the player and releases its media stack: the AVPlayer and the now-playing session
|
|
374
325
|
/// outlive a dropped reference otherwise.
|
|
375
326
|
@MainActor
|
|
376
327
|
func destroyPlayer() {
|
|
328
|
+
Self.destroy(player, engine)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
@MainActor
|
|
332
|
+
private static func destroy(_ player: Player?, _ engine: AVPlayerEngine?) {
|
|
377
333
|
player?.clear()
|
|
378
334
|
engine?.destroy()
|
|
379
335
|
}
|
|
380
336
|
|
|
381
|
-
///
|
|
382
|
-
|
|
337
|
+
/// Forwards the browser's events to JS. The bridge's own events call JS where they are
|
|
338
|
+
/// emitted; their emitters are for native listeners (CarPlay).
|
|
339
|
+
private func forwardBrowserEventsToJS() {
|
|
383
340
|
browserManager.pathChanged.addListener { [weak self] in self?.onPathChanged($0) }
|
|
384
341
|
browserManager.tabsChanged.addListener { [weak self] in self?.onTabsChanged($0) }
|
|
385
342
|
browserManager.contentChanged.addListener { [weak self] in self?.onContentChanged($0) }
|
|
386
|
-
|
|
387
|
-
favoriteChangedEmitter.addListener { [weak self] event in
|
|
388
|
-
self?.onFavoriteChanged(event)
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
activeTrackChangedEmitter.addListener { [weak self] event in
|
|
392
|
-
self?.onPlaybackActiveTrackChanged(event)
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
queueChangedEmitter.addListener { [weak self] tracks in
|
|
396
|
-
self?.onPlaybackQueueChanged(tracks)
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
navigationErrorEmitter.addListener { [weak self] event in
|
|
400
|
-
self?.onNavigationError(event)
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
repeatModeChangedEmitter.addListener { [weak self] event in
|
|
404
|
-
self?.onPlaybackRepeatModeChanged(event)
|
|
405
|
-
}
|
|
406
343
|
}
|
|
407
344
|
}
|
|
408
345
|
|
|
@@ -58,6 +58,21 @@
|
|
|
58
58
|
var album: String?
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
struct PlayerCapabilities: Equatable {
|
|
62
|
+
var play: Bool?
|
|
63
|
+
var pause: Bool?
|
|
64
|
+
var stop: Bool?
|
|
65
|
+
var seekTo: Bool?
|
|
66
|
+
var skipToNext: Bool?
|
|
67
|
+
var skipToPrevious: Bool?
|
|
68
|
+
var jumpForward: Bool?
|
|
69
|
+
var jumpBackward: Bool?
|
|
70
|
+
var favorite: Bool?
|
|
71
|
+
var shuffleMode: Bool?
|
|
72
|
+
var repeatMode: Bool?
|
|
73
|
+
var playbackRate: Bool?
|
|
74
|
+
}
|
|
75
|
+
|
|
61
76
|
struct TrackLoadEvent {
|
|
62
77
|
var track: Track
|
|
63
78
|
var queue: [Track]
|
|
@@ -12,6 +12,8 @@ class PlayerUpdateOptions {
|
|
|
12
12
|
var forwardJumpInterval: Double = 15.0
|
|
13
13
|
var backwardJumpInterval: Double = 15.0
|
|
14
14
|
var progressUpdateEventInterval: Double?
|
|
15
|
+
/// Set by `setPlaybackIntervalEnabled`, not by `update(from:)`; not reported in `toOptions()`.
|
|
16
|
+
var playbackIntervalEnabled = false
|
|
15
17
|
|
|
16
18
|
/// Player capabilities - most enabled by default, only false values disable
|
|
17
19
|
/// Exceptions: jumpForward, jumpBackward default to false
|
|
@@ -1,89 +1,17 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// consumers of the header (Xcode 26.2 / Swift 6.2).
|
|
7
|
-
enum RemoteCommand: CustomStringConvertible, Equatable {
|
|
3
|
+
/// A system remote command (lock screen, Control Center, CarPlay, headset). The skip intervals
|
|
4
|
+
/// and the playback rates the commands offer go to `RemoteCommandController.enable` beside them.
|
|
5
|
+
enum RemoteCommand: CaseIterable, Hashable {
|
|
8
6
|
case play
|
|
9
|
-
|
|
10
7
|
case pause
|
|
11
|
-
|
|
12
8
|
case stop
|
|
13
|
-
|
|
14
|
-
case togglePlayPause
|
|
15
|
-
|
|
16
9
|
case next
|
|
17
|
-
|
|
18
10
|
case previous
|
|
19
|
-
|
|
20
11
|
case changePlaybackPosition
|
|
21
|
-
|
|
22
|
-
case
|
|
23
|
-
|
|
24
|
-
case skipBackward(preferredIntervals: [NSNumber])
|
|
25
|
-
|
|
12
|
+
case skipForward
|
|
13
|
+
case skipBackward
|
|
26
14
|
case changeRepeatMode
|
|
27
|
-
|
|
28
15
|
case changeShuffleMode
|
|
29
|
-
|
|
30
|
-
case changePlaybackRate(supportedPlaybackRates: [NSNumber])
|
|
31
|
-
|
|
32
|
-
var description: String {
|
|
33
|
-
switch self {
|
|
34
|
-
case .play: "play"
|
|
35
|
-
case .pause: "pause"
|
|
36
|
-
case .stop: "stop"
|
|
37
|
-
case .togglePlayPause: "togglePlayPause"
|
|
38
|
-
case .next: "nextTrack"
|
|
39
|
-
case .previous: "previousTrack"
|
|
40
|
-
case .changePlaybackPosition: "changePlaybackPosition"
|
|
41
|
-
case .skipForward: "skipForward"
|
|
42
|
-
case .skipBackward: "skipBackward"
|
|
43
|
-
case .changeRepeatMode: "changeRepeatMode"
|
|
44
|
-
case .changeShuffleMode: "changeShuffleMode"
|
|
45
|
-
case .changePlaybackRate: "changePlaybackRate"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
var key: String { description }
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
Commands in `self` that are absent from `next` and so should be disabled.
|
|
53
|
-
|
|
54
|
-
Compared by `key`, not by `==`. `RemoteCommand` is `Equatable` including its
|
|
55
|
-
associated values, but every case with associated values maps onto a single
|
|
56
|
-
`MPRemoteCommand` addressed by `key`: `.skipForward([15])` and
|
|
57
|
-
`.skipForward([30])` are two different values of one command. Diffing by `==`
|
|
58
|
-
would report the old interval as removed and disable the command that the new
|
|
59
|
-
interval had just re-enabled.
|
|
60
|
-
*/
|
|
61
|
-
static func commandsToDisable(
|
|
62
|
-
enabled: [RemoteCommand],
|
|
63
|
-
replacedBy next: [RemoteCommand],
|
|
64
|
-
) -> [RemoteCommand] {
|
|
65
|
-
let nextKeys = Set(next.map(\.key))
|
|
66
|
-
return enabled.filter { !nextKeys.contains($0.key) }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
All values in an array for convenience.
|
|
71
|
-
Don't use for associated values.
|
|
72
|
-
*/
|
|
73
|
-
static func all() -> [RemoteCommand] {
|
|
74
|
-
[
|
|
75
|
-
.play,
|
|
76
|
-
.pause,
|
|
77
|
-
.stop,
|
|
78
|
-
.togglePlayPause,
|
|
79
|
-
.next,
|
|
80
|
-
.previous,
|
|
81
|
-
.changePlaybackPosition,
|
|
82
|
-
.skipForward(preferredIntervals: []),
|
|
83
|
-
.skipBackward(preferredIntervals: []),
|
|
84
|
-
.changeRepeatMode,
|
|
85
|
-
.changeShuffleMode,
|
|
86
|
-
.changePlaybackRate(supportedPlaybackRates: []),
|
|
87
|
-
]
|
|
88
|
-
}
|
|
16
|
+
case changePlaybackRate
|
|
89
17
|
}
|
|
@@ -18,13 +18,11 @@ final class NowPlayingUpdater {
|
|
|
18
18
|
var onChanged: ((NowPlayingMetadata) -> Void)?
|
|
19
19
|
|
|
20
20
|
/// When false the surface shows the track's own fields: the override and formatter are ignored.
|
|
21
|
-
|
|
21
|
+
private let enabled: Bool
|
|
22
22
|
var formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
|
|
23
23
|
|
|
24
|
-
///
|
|
25
|
-
var
|
|
26
|
-
didSet { if isOnline != oldValue { render() } }
|
|
27
|
-
}
|
|
24
|
+
/// Tells a stall apart: offline or buffering.
|
|
25
|
+
private weak var networkStatus: (any NetworkStatusProviding)?
|
|
28
26
|
|
|
29
27
|
private var override: NowPlayingUpdate?
|
|
30
28
|
private var timedMetadata: TimedMetadata?
|
|
@@ -103,8 +101,16 @@ final class NowPlayingUpdater {
|
|
|
103
101
|
/// formatter result (a flash overwrites it, `clearNowPlaying` empties it).
|
|
104
102
|
private var formattedTrackId: String?
|
|
105
103
|
|
|
106
|
-
init(
|
|
104
|
+
init(
|
|
105
|
+
nowPlayingInfoController: NowPlayingInfoController,
|
|
106
|
+
networkStatus: (any NetworkStatusProviding)?,
|
|
107
|
+
enabled: Bool,
|
|
108
|
+
formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
|
|
109
|
+
) {
|
|
107
110
|
self.nowPlayingInfoController = nowPlayingInfoController
|
|
111
|
+
self.networkStatus = networkStatus
|
|
112
|
+
self.enabled = enabled
|
|
113
|
+
self.formatter = formatter
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/// Pixel width to request now-playing artwork at: the active scene's screen
|
|
@@ -169,7 +175,7 @@ final class NowPlayingUpdater {
|
|
|
169
175
|
track: track,
|
|
170
176
|
timedMetadata: timedMetadata,
|
|
171
177
|
playWhenReady: source.playWhenReady,
|
|
172
|
-
stalled: source.isStalled ? (isOnline ? .buffering : .offline) : nil,
|
|
178
|
+
stalled: source.isStalled ? ((networkStatus?.isOnline ?? true) ? .buffering : .offline) : nil,
|
|
173
179
|
error: source.nitroPlaybackError,
|
|
174
180
|
)
|
|
175
181
|
// `@Sendable` breaks the @MainActor isolation inheritance: Nitro resolves the promise on the JS
|
package/ios/Player/Player.swift
CHANGED
|
@@ -216,12 +216,14 @@ final class Player {
|
|
|
216
216
|
/// - persistenceSaveInterval: How often the snapshot is saved while the play intent is active.
|
|
217
217
|
init(
|
|
218
218
|
engine: any PlayerEngine,
|
|
219
|
+
callbacks: PlayerCallbacks? = nil,
|
|
219
220
|
retry: RetryLoop = RetryLoop(),
|
|
220
221
|
sleepTimerScheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler(),
|
|
221
222
|
persistenceDefaults: UserDefaults = PlaybackPersistence.standardDefaults,
|
|
222
223
|
persistenceSaveInterval: Duration = .seconds(5),
|
|
223
224
|
) {
|
|
224
225
|
self.engine = engine
|
|
226
|
+
self.callbacks = callbacks
|
|
225
227
|
self.retry = retry
|
|
226
228
|
sleepTimerManager = SleepTimerManager(
|
|
227
229
|
fader: VolumeFader(
|
|
@@ -290,7 +292,8 @@ final class Player {
|
|
|
290
292
|
|
|
291
293
|
/// Emits what changed since the last publish, in order: the play intent, the playing state,
|
|
292
294
|
/// the playback (state and error), the terminal error, the active play intent. The one place
|
|
293
|
-
/// these leave the Player, so each goes out once per change
|
|
295
|
+
/// these leave the Player, so each goes out once per change, with the audio-session and
|
|
296
|
+
/// now-playing effects it drives.
|
|
294
297
|
private func publish() {
|
|
295
298
|
let playing = playingState
|
|
296
299
|
let now = Published(
|
|
@@ -302,13 +305,19 @@ final class Player {
|
|
|
302
305
|
guard now != old else { return }
|
|
303
306
|
published = now
|
|
304
307
|
if now.playWhenReady != old.playWhenReady {
|
|
308
|
+
if now.playWhenReady { engine.cancelAudioSessionRelease() }
|
|
305
309
|
callbacks?.playerDidChangePlayWhenReady(now.playWhenReady)
|
|
306
310
|
}
|
|
307
311
|
if now.playing != old.playing || now.buffering != old.buffering {
|
|
308
312
|
callbacks?.playerDidChangePlayingState(playing)
|
|
313
|
+
// Activated on output, not at setup: activating at launch interrupts other apps' audio.
|
|
314
|
+
if playing.playing || playing.buffering { engine.activateAudioSession() }
|
|
315
|
+
// A stall starting or recovering may leave the state as it was.
|
|
316
|
+
engine.renderNowPlaying()
|
|
309
317
|
}
|
|
310
318
|
if now.state != old.state || now.errorStatus != old.errorStatus {
|
|
311
319
|
callbacks?.playerDidChangePlayback(Playback(state: now.state, error: now.errorStatus.nitroError))
|
|
320
|
+
engine.renderNowPlaying()
|
|
312
321
|
}
|
|
313
322
|
let erred = now.state == .error, wasErred = old.state == .error
|
|
314
323
|
if erred != wasErred || (erred && now.errorStatus != old.errorStatus) {
|
|
@@ -399,13 +408,14 @@ final class Player {
|
|
|
399
408
|
}
|
|
400
409
|
|
|
401
410
|
/// Requests a session release once playback is deliberately stopped: no intent, no
|
|
402
|
-
/// interruption to resume from, no retry pending
|
|
411
|
+
/// interruption to resume from, and no retry pending, whose reconnect needs the session. The
|
|
412
|
+
/// engine debounces it.
|
|
403
413
|
private func evaluateSessionRelease() {
|
|
404
414
|
guard !playWhenReady,
|
|
405
415
|
!shouldResumeAfterInterruption,
|
|
406
416
|
retry.pendingTask == nil
|
|
407
417
|
else { return }
|
|
408
|
-
|
|
418
|
+
engine.scheduleAudioSessionRelease()
|
|
409
419
|
}
|
|
410
420
|
|
|
411
421
|
func getPlayback() -> Playback {
|
|
@@ -599,6 +609,7 @@ final class Player {
|
|
|
599
609
|
track: currentTrack,
|
|
600
610
|
)
|
|
601
611
|
callbacks?.playerDidChangeActiveTrack(eventData)
|
|
612
|
+
engine.nowPlayingTrackChanged()
|
|
602
613
|
lastTrack = currentTrack
|
|
603
614
|
lastIndex = queue.currentIndex
|
|
604
615
|
}
|
|
@@ -755,6 +766,9 @@ final class Player {
|
|
|
755
766
|
case .stalled:
|
|
756
767
|
// Nudge, don't reconnect: a genuine drop is recovered by the retry and network paths.
|
|
757
768
|
recoverFromStall(reconnectIfLive: false)
|
|
769
|
+
case let .timedMetadata(metadata):
|
|
770
|
+
callbacks?.playerDidReceiveTimedMetadata(metadata)
|
|
771
|
+
engine.applyTimedMetadata(metadata)
|
|
758
772
|
}
|
|
759
773
|
}
|
|
760
774
|
|
|
@@ -839,13 +853,15 @@ final class Player {
|
|
|
839
853
|
|
|
840
854
|
// MARK: - Connectivity
|
|
841
855
|
|
|
842
|
-
/// Re-
|
|
843
|
-
///
|
|
844
|
-
///
|
|
845
|
-
///
|
|
846
|
-
/// which owns
|
|
847
|
-
|
|
848
|
-
|
|
856
|
+
/// Re-renders the now-playing lines, whose stall line tells offline from buffering, and when
|
|
857
|
+
/// connectivity returns re-establishes a stalled stream. AVPlayer doesn't reliably report a
|
|
858
|
+
/// live stream's mid-stream drop as a failure: it waits with no error, so the retry loop never
|
|
859
|
+
/// engages. Only while stalled (.buffering) with play intent, so a pause or a retry that gave up
|
|
860
|
+
/// isn't resumed; and not while the retry loop is itself waiting for the network, which owns
|
|
861
|
+
/// that reload.
|
|
862
|
+
func networkStatusDidChange(isOnline: Bool) {
|
|
863
|
+
engine.renderNowPlaying()
|
|
864
|
+
guard isOnline, playWhenReady, state == .buffering, !retry.isWaitingForNetwork else { return }
|
|
849
865
|
logger.info("Connectivity restored while stalled — re-resolving to reconnect")
|
|
850
866
|
reloadResolving(startFromCurrentTime: true)
|
|
851
867
|
}
|
|
@@ -15,9 +15,6 @@
|
|
|
15
15
|
func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent)
|
|
16
16
|
func playerDidChangeShuffleEnabled(_ enabled: Bool)
|
|
17
17
|
func playerDidError(_ event: PlaybackErrorEvent)
|
|
18
|
-
|
|
19
|
-
/// (those resume), and no retry is pending (a live-stream reconnect must keep the session). The
|
|
20
|
-
/// host may release the audio session (debounced) so other apps' audio can resume.
|
|
21
|
-
func playerShouldReleaseSession()
|
|
18
|
+
func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata)
|
|
22
19
|
func playerDidChangeSleepTimer(_ state: SleepTimerState?)
|
|
23
20
|
}
|
|
@@ -24,6 +24,9 @@ enum EngineEvent {
|
|
|
24
24
|
case playedToEnd
|
|
25
25
|
/// The buffer emptied mid-playback.
|
|
26
26
|
case stalled
|
|
27
|
+
/// A timed-metadata group (a live stream's song line), decoded with the loaded track's ICY
|
|
28
|
+
/// charset.
|
|
29
|
+
case timedMetadata(TimedMetadata)
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/// The media stack under the Player: the AVPlayer and its items, media loading, now playing and
|
|
@@ -80,8 +83,22 @@ enum EngineEvent {
|
|
|
80
83
|
func primeNowPlaying()
|
|
81
84
|
func loadNowPlayingMetadata(for track: Track)
|
|
82
85
|
func clearNowPlaying()
|
|
86
|
+
/// Re-renders the now-playing lines from the Player's state.
|
|
87
|
+
func renderNowPlaying()
|
|
88
|
+
/// Drops the previous track's override, flash and timed metadata, and re-renders.
|
|
89
|
+
func nowPlayingTrackChanged()
|
|
90
|
+
func applyTimedMetadata(_ metadata: TimedMetadata)
|
|
83
91
|
func updateRemoteRepeatMode(_ mode: RepeatMode)
|
|
84
92
|
func updateRemoteShuffleMode(_ enabled: Bool)
|
|
85
93
|
/// Greys out next/previous on the remote surfaces when the queue has nowhere to skip.
|
|
86
94
|
func updateSkipAvailability(canNext: Bool, canPrevious: Bool)
|
|
95
|
+
|
|
96
|
+
// MARK: Audio session
|
|
97
|
+
|
|
98
|
+
/// Activates the audio session off the main thread, then re-requests the now-playing session,
|
|
99
|
+
/// which the system may have declined while the audio session was inactive.
|
|
100
|
+
func activateAudioSession()
|
|
101
|
+
func cancelAudioSessionRelease()
|
|
102
|
+
/// Releases the audio session after a debounce, unless playback resumes first.
|
|
103
|
+
func scheduleAudioSessionRelease()
|
|
87
104
|
}
|