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.
Files changed (35) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
  4. package/ios/CarPlay/CarPlaySession.swift +3 -3
  5. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  6. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  7. package/ios/Extension/Track+AVPlayer.swift +0 -16
  8. package/ios/HybridAudioBrowser+Browser.swift +16 -17
  9. package/ios/HybridAudioBrowser+Options.swift +27 -66
  10. package/ios/HybridAudioBrowser+Player.swift +4 -60
  11. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  12. package/ios/HybridAudioBrowser.swift +36 -99
  13. package/ios/Model/NitroTypeStubs.swift +15 -0
  14. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  15. package/ios/Model/RemoteCommand.swift +6 -78
  16. package/ios/Player/NowPlayingUpdater.swift +13 -7
  17. package/ios/Player/Player.swift +26 -10
  18. package/ios/Player/PlayerCallbacks.swift +1 -4
  19. package/ios/Player/PlayerEngine.swift +17 -0
  20. package/ios/Player/RetryLoop.swift +5 -1
  21. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  22. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  23. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  24. package/ios/Tests/EmitterTests.swift +3 -48
  25. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  26. package/ios/Tests/PlayerTests.swift +95 -17
  27. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  28. package/ios/Tests/Support/TestTypes.swift +44 -3
  29. package/ios/TrackSelector.swift +34 -52
  30. package/ios/Util/Emitter.swift +0 -14
  31. package/ios/Util/NetworkMonitor.swift +8 -25
  32. package/ios/Util/OutputPicker.swift +48 -0
  33. package/package.json +1 -1
  34. package/ios/Model/SourceType.swift +0 -6
  35. 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(_ groups: [AVTimedMetadataGroup]) {
104
- for group in groups {
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.getPlayer() else { return nil }
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
- /// Installed once, on first instance creation. React Native posts this
38
- /// notification (from `RCTReloadCommand`) when a JS reload — dev or
39
- /// OTA-update restart — is triggered, *before* the old runtime is destroyed:
40
- /// the earliest signal that this instance's JS callbacks are about to go
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?.navigationErrorEmitter.emit(NavigationErrorEvent(error: $0)) },
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: - Multi-Listener Emitters
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
- // The monitor->JS bridge is wired centrally in setupPlayer (so it doesn't depend on a JS
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
- setupEmitterToNitroForwarding()
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 *previous* instance when a new one takes over: its JS runtime
323
- /// is gone, and invoking a callback from a dead runtime aborts the process —
324
- /// Nitro's `AsyncJSCallback` throws on a destroyed Dispatcher, and the throw
325
- /// crosses the generated bridge wrapper's `noexcept` boundary. Native code
326
- /// (e.g. a CarPlay controller still bound to this instance mid-reload) keeps
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: ensure the AVPlayer is stopped if this instance is deallocated.
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
- if Thread.isMainThread {
354
- MainActor.assumeIsolated {
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
- /// Sets up automatic forwarding from emitters to Nitro callbacks
382
- private func setupEmitterToNitroForwarding() {
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
- // Internal on purpose: nothing outside the module uses this, and a public
4
- // declaration gets printed into the generated -Swift.h C++ interop section,
5
- // where the [NSNumber] associated values don't compile for plain-C++
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 skipForward(preferredIntervals: [NSNumber])
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
- var enabled = true
21
+ private let enabled: Bool
22
22
  var formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
23
23
 
24
- /// Whether the device is online, which tells a stall apart: offline or buffering.
25
- var isOnline = true {
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(nowPlayingInfoController: NowPlayingInfoController) {
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
@@ -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. The host debounces it.
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
- callbacks?.playerShouldReleaseSession()
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-establishes a stalled stream when connectivity returns. AVPlayer doesn't reliably report
843
- /// a live stream's mid-stream drop as a failure: it waits with no error, so the retry loop
844
- /// never engages. Only while stalled (.buffering) with play intent, so a pause or a retry that
845
- /// gave up isn't resumed; and not while the retry loop is itself waiting for the network,
846
- /// which owns that reload.
847
- func networkDidReturn() {
848
- guard playWhenReady, state == .buffering, !retry.isWaitingForNetwork else { return }
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
- /// Playback is deliberately stopped — play intent is off, it isn't an interruption-driven pause
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
  }