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
@@ -101,7 +101,11 @@ final class RetryLoop {
101
101
  private let sleep: @Sendable (TimeInterval) async throws -> Void
102
102
 
103
103
  /// - Parameter sleep: Waits out a backoff or a connectivity poll; tests make it instant.
104
- init(sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) {
104
+ init(
105
+ networkMonitor: (any NetworkStatusProviding)? = nil,
106
+ sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) },
107
+ ) {
108
+ self.networkMonitor = networkMonitor
105
109
  self.sleep = sleep
106
110
  }
107
111
 
@@ -17,9 +17,6 @@
17
17
  /// Called when stop is triggered remotely.
18
18
  func remoteStop()
19
19
 
20
- /// Called when toggle play/pause is triggered remotely.
21
- func remotePlayPause()
22
-
23
20
  /// Called when next is triggered remotely.
24
21
  func remoteNext()
25
22
 
@@ -39,7 +39,10 @@ final class RemoteCommandController {
39
39
 
40
40
  weak var callbacks: RemoteCommandCallbacks?
41
41
 
42
- private var enabledCommands: [RemoteCommand] = []
42
+ private var enabledCommands: Set<RemoteCommand> = []
43
+ private var forwardJumpInterval: Double = 15
44
+ private var backwardJumpInterval: Double = 15
45
+ private var playbackRates: [Double] = []
43
46
 
44
47
  /// Whether the queue has a next / previous track; read whenever those commands are enabled.
45
48
  var skipAvailability: @MainActor () -> (canNext: Bool, canPrevious: Bool) = { (true, true) }
@@ -62,18 +65,27 @@ final class RemoteCommandController {
62
65
  func switchCommandCenter(_ newCenter: MPRemoteCommandCenter) {
63
66
  guard newCenter !== center else { return }
64
67
  let commands = enabledCommands
65
- disableAll()
68
+ commands.forEach(disable)
66
69
  center = newCenter
67
- enable(commands: commands)
70
+ commands.forEach(enable)
68
71
  center.changeShuffleModeCommand.currentShuffleType = shuffleEnabled.mpShuffleType
69
72
  center.changeRepeatModeCommand.currentRepeatType = repeatMode.mpRepeatType
70
73
  logger.info("Switched command center, re-enabled \(commands.count) commands")
71
74
  }
72
75
 
73
- /// Enables exactly `commands`, disabling any enabled command they no longer include.
74
- func enable(commands: [RemoteCommand]) {
75
- RemoteCommand.commandsToDisable(enabled: enabledCommands, replacedBy: commands).forEach(disable)
76
+ /// Enables exactly `commands`, disabling any enabled command they no longer include. The skip
77
+ /// commands offer their interval, the rate command `playbackRates`.
78
+ func enable(
79
+ commands: Set<RemoteCommand>,
80
+ forwardJumpInterval: Double = 15,
81
+ backwardJumpInterval: Double = 15,
82
+ playbackRates: [Double] = [],
83
+ ) {
84
+ enabledCommands.subtracting(commands).forEach(disable)
76
85
  enabledCommands = commands
86
+ self.forwardJumpInterval = forwardJumpInterval
87
+ self.backwardJumpInterval = backwardJumpInterval
88
+ self.playbackRates = playbackRates
77
89
  commands.forEach(enable)
78
90
  }
79
91
 
@@ -106,7 +118,6 @@ final class RemoteCommandController {
106
118
  case .play: center.playCommand
107
119
  case .pause: center.pauseCommand
108
120
  case .stop: center.stopCommand
109
- case .togglePlayPause: center.togglePlayPauseCommand
110
121
  case .next: center.nextTrackCommand
111
122
  case .previous: center.previousTrackCommand
112
123
  case .changePlaybackPosition: center.changePlaybackPositionCommand
@@ -120,9 +131,10 @@ final class RemoteCommandController {
120
131
 
121
132
  private func enable(_ command: RemoteCommand) {
122
133
  switch command {
123
- case let .skipForward(intervals): center.skipForwardCommand.preferredIntervals = intervals
124
- case let .skipBackward(intervals): center.skipBackwardCommand.preferredIntervals = intervals
125
- case let .changePlaybackRate(rates): center.changePlaybackRateCommand.supportedPlaybackRates = rates
134
+ case .skipForward: center.skipForwardCommand.preferredIntervals = [NSNumber(value: forwardJumpInterval)]
135
+ case .skipBackward: center.skipBackwardCommand.preferredIntervals = [NSNumber(value: backwardJumpInterval)]
136
+ case .changePlaybackRate:
137
+ center.changePlaybackRateCommand.supportedPlaybackRates = playbackRates.map { NSNumber(value: $0) }
126
138
  default: break
127
139
  }
128
140
  let mpCommand = mpCommand(for: command)
@@ -148,14 +160,13 @@ final class RemoteCommandController {
148
160
  case .play: callbacks?.remotePlay()
149
161
  case .pause: callbacks?.remotePause()
150
162
  case .stop: callbacks?.remoteStop()
151
- case .togglePlayPause: callbacks?.remotePlayPause()
152
163
  case .next: callbacks?.remoteNext()
153
164
  case .previous: callbacks?.remotePrevious()
154
165
  case .skipForward, .skipBackward:
155
166
  guard let interval = (event.command as? MPSkipIntervalCommand)?.preferredIntervals.first else {
156
167
  return .commandFailed
157
168
  }
158
- if case .skipForward = command {
169
+ if command == .skipForward {
159
170
  callbacks?.remoteJumpForward(interval: interval.doubleValue)
160
171
  } else {
161
172
  callbacks?.remoteJumpBackward(interval: interval.doubleValue)
@@ -0,0 +1,35 @@
1
+ import Testing
2
+
3
+ @testable import AudioBrowserTestable
4
+
5
+ @Suite("PlayerCapabilities.remoteCommands")
6
+ struct CapabilityRemoteCommandTests {
7
+ @Test func everyCommandIsEnabledUnlessSetToFalse() {
8
+ #expect(PlayerCapabilities().remoteCommands == Set(RemoteCommand.allCases))
9
+ }
10
+
11
+ @Test func eachCapabilitySetToFalseDropsExactlyItsCommand() {
12
+ let pairs: [(WritableKeyPath<PlayerCapabilities, Bool?>, RemoteCommand)] = [
13
+ (\.play, .play),
14
+ (\.pause, .pause),
15
+ (\.stop, .stop),
16
+ (\.skipToNext, .next),
17
+ (\.skipToPrevious, .previous),
18
+ (\.seekTo, .changePlaybackPosition),
19
+ (\.jumpForward, .skipForward),
20
+ (\.jumpBackward, .skipBackward),
21
+ (\.shuffleMode, .changeShuffleMode),
22
+ (\.repeatMode, .changeRepeatMode),
23
+ (\.playbackRate, .changePlaybackRate),
24
+ ]
25
+ for (capability, command) in pairs {
26
+ var capabilities = PlayerCapabilities()
27
+ capabilities[keyPath: capability] = false
28
+ #expect(capabilities.remoteCommands == Set(RemoteCommand.allCases).subtracting([command]), "\(command)")
29
+ }
30
+ }
31
+
32
+ @Test func favoriteEnablesNoCommand() {
33
+ #expect(PlayerCapabilities(favorite: false).remoteCommands == Set(RemoteCommand.allCases))
34
+ }
35
+ }
@@ -69,55 +69,12 @@ struct RemoveListenerTests {
69
69
 
70
70
  @Test func doubleRemoveSameTokenIsSafe() {
71
71
  let emitter = Emitter<Int>()
72
- let token = emitter.addListener { _ in }
72
+ var received = [Int]()
73
+ let token = emitter.addListener { received.append($0) }
73
74
  emitter.removeListener(token)
74
75
  emitter.removeListener(token)
75
- #expect(emitter.listenerCount == 0)
76
- }
77
- }
78
-
79
- // MARK: - removeAllListeners
80
-
81
- @Suite("removeAllListeners")
82
- struct RemoveAllListenersTests {
83
- @Test func clearsAllAndCountGoesToZero() {
84
- let emitter = Emitter<Int>()
85
- emitter.addListener { _ in }
86
- emitter.addListener { _ in }
87
- emitter.addListener { _ in }
88
- emitter.removeAllListeners()
89
- #expect(emitter.listenerCount == 0)
90
- }
91
-
92
- @Test func previouslyAddedListenersStopReceiving() {
93
- let emitter = Emitter<Int>()
94
- var called = false
95
- emitter.addListener { _ in called = true }
96
- emitter.removeAllListeners()
97
76
  emitter.emit(1)
98
- #expect(called == false)
99
- }
100
- }
101
-
102
- // MARK: - listenerCount
103
-
104
- @Suite("listenerCount")
105
- struct ListenerCountTests {
106
- @Test func reflectsAddsAndRemoves() {
107
- let emitter = Emitter<Int>()
108
- #expect(emitter.listenerCount == 0)
109
-
110
- let t1 = emitter.addListener { _ in }
111
- #expect(emitter.listenerCount == 1)
112
-
113
- let t2 = emitter.addListener { _ in }
114
- #expect(emitter.listenerCount == 2)
115
-
116
- emitter.removeListener(t1)
117
- #expect(emitter.listenerCount == 1)
118
-
119
- emitter.removeListener(t2)
120
- #expect(emitter.listenerCount == 0)
77
+ #expect(received.isEmpty)
121
78
  }
122
79
  }
123
80
 
@@ -133,7 +90,6 @@ struct SubscribeTests {
133
90
  subscription.cancel()
134
91
  emitter.emit(2)
135
92
  #expect(received == [1])
136
- #expect(emitter.listenerCount == 0)
137
93
  }
138
94
 
139
95
  @Test func releasingTheSubscriptionRemovesTheListener() {
@@ -146,6 +102,5 @@ struct SubscribeTests {
146
102
  }
147
103
  emitter.emit(2)
148
104
  #expect(received == [1])
149
- #expect(emitter.listenerCount == 0)
150
105
  }
151
106
  }
@@ -177,18 +177,42 @@ struct PlayerRecoveringTests {
177
177
  let (player, engine, _) = makePlaying()
178
178
  player.transition(.avPlayerWaiting)
179
179
  #expect(player.state == .buffering)
180
- player.networkDidReturn()
180
+ player.networkStatusDidChange(isOnline: true)
181
181
  #expect(engine.resolveAndLoadCalls.count == 2)
182
182
  }
183
183
 
184
184
  @Test func theNetworkReturningLeavesAPausedOrPlayingStreamAlone() {
185
185
  let (player, engine, _) = makePlaying()
186
- player.networkDidReturn()
186
+ player.networkStatusDidChange(isOnline: true)
187
187
  player.pause()
188
- player.networkDidReturn()
188
+ player.networkStatusDidChange(isOnline: true)
189
189
  #expect(engine.resolveAndLoadCalls.count == 1)
190
190
  }
191
191
 
192
+ @Test func theNetworkGoingReRendersTheStallLineWithoutReconnecting() {
193
+ let (player, engine, _) = makePlaying()
194
+ player.transition(.avPlayerWaiting)
195
+ engine.nowPlayingRenderCount = 0
196
+
197
+ player.networkStatusDidChange(isOnline: false)
198
+
199
+ #expect(engine.nowPlayingRenderCount == 1)
200
+ #expect(engine.resolveAndLoadCalls.count == 1)
201
+ }
202
+
203
+ @Test func theNetworkReturningReRendersBeforeReconnecting() {
204
+ let (player, engine, _) = makePlaying()
205
+ player.transition(.avPlayerWaiting)
206
+ var order: [String] = []
207
+ engine.onSeamCall = { order.append($0) }
208
+ let loadsBefore = engine.resolveAndLoadCalls.count
209
+
210
+ player.networkStatusDidChange(isOnline: true)
211
+
212
+ #expect(order.first == "renderNowPlaying")
213
+ #expect(engine.resolveAndLoadCalls.count == loadsBefore + 1)
214
+ }
215
+
192
216
  /// A restart from a terminal error is a new Load: fresh retry budgets, unproven playback, the
193
217
  /// primer allowed again; a retry within a Load keeps its Load.
194
218
  @Test func aRestartFromAnErrorBeginsANewLoad() async {
@@ -681,13 +681,13 @@ struct NaturalEndPlayIntentTests {
681
681
 
682
682
  @Test @MainActor
683
683
  func naturalEnd_requestsSessionRelease() {
684
- let (c, eh, callbacks, _) = makeCoordinator()
684
+ let (c, eh, _, _) = makeCoordinator()
685
685
  startPlaying(c, eh)
686
- callbacks.releaseSessionCount = 0
686
+ eh.scheduleAudioSessionReleaseCount = 0
687
687
 
688
688
  eh.emit(.playedToEnd)
689
689
 
690
- #expect(callbacks.releaseSessionCount == 1)
690
+ #expect(eh.scheduleAudioSessionReleaseCount == 1)
691
691
  }
692
692
 
693
693
  /// play() from .ended must reload from the start — startPlayback() alone is a
@@ -1272,48 +1272,126 @@ struct StopCancelsRetryTests {
1272
1272
  struct SessionReleaseTests {
1273
1273
  @Test @MainActor
1274
1274
  func deliberatePause_requestsRelease() {
1275
- let (c, eh, callbacks, _) = makeCoordinator()
1275
+ let (c, eh, _, _) = makeCoordinator()
1276
1276
  startPlaying(c, eh)
1277
- callbacks.releaseSessionCount = 0
1277
+ eh.scheduleAudioSessionReleaseCount = 0
1278
1278
  c.pause()
1279
- #expect(callbacks.releaseSessionCount == 1)
1279
+ #expect(eh.scheduleAudioSessionReleaseCount == 1)
1280
1280
  }
1281
1281
 
1282
1282
  @Test @MainActor
1283
1283
  func play_doesNotRequestRelease() {
1284
- let (c, eh, callbacks, _) = makeCoordinator()
1284
+ let (c, eh, _, _) = makeCoordinator()
1285
1285
  startPlaying(c, eh)
1286
- #expect(callbacks.releaseSessionCount == 0)
1286
+ #expect(eh.scheduleAudioSessionReleaseCount == 0)
1287
1287
  }
1288
1288
 
1289
1289
  @Test @MainActor
1290
1290
  func interruptionBegan_doesNotRequestRelease() {
1291
1291
  // A phone call pauses (playWhenReady → false) but is meant to resume, so the session must be
1292
1292
  // held — the exact race the intent-gating design exists to avoid.
1293
- let (c, eh, callbacks, _) = makeCoordinator()
1293
+ let (c, eh, _, _) = makeCoordinator()
1294
1294
  startPlaying(c, eh)
1295
- callbacks.releaseSessionCount = 0
1295
+ eh.scheduleAudioSessionReleaseCount = 0
1296
1296
  c.handleInterruptionBegan()
1297
- #expect(callbacks.releaseSessionCount == 0)
1297
+ #expect(eh.scheduleAudioSessionReleaseCount == 0)
1298
1298
  }
1299
1299
 
1300
1300
  @Test @MainActor
1301
1301
  func interruptionEnded_withResume_doesNotRequestRelease() {
1302
- let (c, eh, callbacks, _) = makeCoordinator()
1302
+ let (c, eh, _, _) = makeCoordinator()
1303
1303
  startPlaying(c, eh)
1304
1304
  c.handleInterruptionBegan()
1305
- callbacks.releaseSessionCount = 0
1305
+ eh.scheduleAudioSessionReleaseCount = 0
1306
1306
  c.handleInterruptionEnded(shouldResume: true)
1307
- #expect(callbacks.releaseSessionCount == 0)
1307
+ #expect(eh.scheduleAudioSessionReleaseCount == 0)
1308
1308
  }
1309
1309
 
1310
1310
  @Test @MainActor
1311
1311
  func interruptionEnded_withoutResume_requestsRelease() {
1312
- let (c, eh, callbacks, _) = makeCoordinator()
1312
+ let (c, eh, _, _) = makeCoordinator()
1313
1313
  startPlaying(c, eh)
1314
1314
  c.handleInterruptionBegan()
1315
- callbacks.releaseSessionCount = 0
1315
+ eh.scheduleAudioSessionReleaseCount = 0
1316
1316
  c.handleInterruptionEnded(shouldResume: false)
1317
- #expect(callbacks.releaseSessionCount == 1)
1317
+ #expect(eh.scheduleAudioSessionReleaseCount == 1)
1318
+ }
1319
+ }
1320
+
1321
+ // MARK: - Media-stack effects of a publish
1322
+
1323
+ /// The audio-session and now-playing effects the Player drives through its engine, in their
1324
+ /// order relative to the callbacks JS hears.
1325
+ @Suite("Player - media-stack effects")
1326
+ struct MediaStackEffectTests {
1327
+ @MainActor
1328
+ private func makeTraced() -> (Player, MockPlayerEngine, MockPlayerCallbacks) {
1329
+ let (c, eh, cb, _) = makeCoordinator()
1330
+ eh.onSeamCall = { [weak cb] in cb?.publishOrder.append($0) }
1331
+ return (c, eh, cb)
1332
+ }
1333
+
1334
+ @Test @MainActor
1335
+ func playIntent_cancelsTheReleaseBeforeJSHearsIt_thenActivatesOnOutput() {
1336
+ let (c, _, cb) = makeTraced()
1337
+ loadTrack(c)
1338
+ cb.publishOrder.removeAll()
1339
+
1340
+ c.setPlayWhenReady(true)
1341
+
1342
+ #expect(cb.publishOrder == [
1343
+ "cancelAudioSessionRelease", "playWhenReady",
1344
+ "playingState", "activateAudioSession", "renderNowPlaying",
1345
+ ])
1346
+ }
1347
+
1348
+ @Test @MainActor
1349
+ func pause_rendersWithoutActivating_thenSchedulesTheRelease() {
1350
+ let (c, eh, cb) = makeTraced()
1351
+ startPlaying(c, eh)
1352
+ cb.publishOrder.removeAll()
1353
+
1354
+ c.pause()
1355
+
1356
+ #expect(cb.publishOrder == [
1357
+ "playWhenReady", "playingState", "renderNowPlaying", "scheduleAudioSessionRelease",
1358
+ ])
1359
+ }
1360
+
1361
+ @Test @MainActor
1362
+ func playbackChange_rendersAfterJSHearsIt() {
1363
+ let (c, _, cb) = makeTraced()
1364
+ loadTrack(c)
1365
+ cb.publishOrder.removeAll()
1366
+
1367
+ c.transition(.errorOccurred(.playbackFailed))
1368
+
1369
+ #expect(cb.publishOrder == ["playback", "renderNowPlaying"])
1370
+ }
1371
+
1372
+ @Test @MainActor
1373
+ func activeTrackChange_resetsTheNowPlayingLinesAfterJSHearsIt() {
1374
+ let (c, eh, cb, _) = makeCoordinator()
1375
+ eh.onSeamCall = { [weak cb] call in
1376
+ if call == "nowPlayingTrackChanged" { cb?.queueAndTrackOrder.append(call) }
1377
+ }
1378
+
1379
+ c.setQueue([Track(id: "t1", src: "https://example.com/a.mp3", title: "A")])
1380
+
1381
+ #expect(cb.queueAndTrackOrder == ["queue", "activeTrack", "nowPlayingTrackChanged"])
1382
+ #expect(eh.nowPlayingTrackChangedCount == 1)
1383
+ }
1384
+
1385
+ @Test @MainActor
1386
+ func timedMetadata_reachesJSThenTheNowPlayingLines() {
1387
+ let (c, eh, cb) = makeTraced()
1388
+ let metadata = TimedMetadata(title: "Song", artist: "Artist")
1389
+
1390
+ eh.emit(.timedMetadata(metadata))
1391
+ withExtendedLifetime(c) {}
1392
+
1393
+ #expect(cb.publishOrder == ["timedMetadata", "applyTimedMetadata"])
1394
+ #expect(cb.timedMetadata == [metadata])
1395
+ #expect(eh.appliedTimedMetadata == [metadata])
1318
1396
  }
1319
1397
  }
@@ -18,7 +18,6 @@ struct RemoteCommandControllerTests {
18
18
  func remotePlay() { events.append("play") }
19
19
  func remotePause() { events.append("pause") }
20
20
  func remoteStop() { events.append("stop") }
21
- func remotePlayPause() { events.append("playPause") }
22
21
  func remoteNext() { events.append("next") }
23
22
  func remotePrevious() { events.append("previous") }
24
23
  func remoteJumpForward(interval: Double) { events.append("jumpForward:\(interval)") }
@@ -34,28 +33,23 @@ struct RemoteCommandControllerTests {
34
33
  private func makeController() -> (RemoteCommandController, SpyCallbacks) {
35
34
  let center = MPRemoteCommandCenter.shared()
36
35
  let reset = RemoteCommandController(remoteCommandCenter: center)
37
- reset.enable(commands: RemoteCommand.all())
36
+ reset.enable(commands: Set(RemoteCommand.allCases))
38
37
  reset.disableAll()
39
38
 
40
39
  let spy = SpyCallbacks()
41
40
  return (RemoteCommandController(remoteCommandCenter: center, callbacks: spy), spy)
42
41
  }
43
42
 
44
- /// The reported bug: a changed jump interval used to disable the very command
45
- /// it had just re-enabled, removing the skip button from the lock screen,
46
- /// Control Center and CarPlay while `enabledCommands` still reported it on.
47
43
  @Test func changedJumpIntervalKeepsCommandEnabled() {
48
44
  let (controller, _) = makeController()
49
45
  let center = MPRemoteCommandCenter.shared()
50
46
 
51
- controller.enable(commands: [
52
- .play, .skipForward(preferredIntervals: [15]), .skipBackward(preferredIntervals: [15]),
53
- ])
47
+ controller.enable(commands: [.play, .skipForward, .skipBackward])
54
48
  #expect(center.skipForwardCommand.isEnabled)
55
49
 
56
- controller.enable(commands: [
57
- .play, .skipForward(preferredIntervals: [30]), .skipBackward(preferredIntervals: [30]),
58
- ])
50
+ controller.enable(
51
+ commands: [.play, .skipForward, .skipBackward], forwardJumpInterval: 30, backwardJumpInterval: 30,
52
+ )
59
53
 
60
54
  #expect(center.skipForwardCommand.isEnabled)
61
55
  #expect(center.skipBackwardCommand.isEnabled)
@@ -67,8 +61,8 @@ struct RemoteCommandControllerTests {
67
61
  let (controller, _) = makeController()
68
62
  let center = MPRemoteCommandCenter.shared()
69
63
 
70
- controller.enable(commands: [.changePlaybackRate(supportedPlaybackRates: [1.0])])
71
- controller.enable(commands: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])])
64
+ controller.enable(commands: [.changePlaybackRate], playbackRates: [1.0])
65
+ controller.enable(commands: [.changePlaybackRate], playbackRates: [1.0, 1.5, 2.0])
72
66
 
73
67
  #expect(center.changePlaybackRateCommand.isEnabled)
74
68
  #expect(center.changePlaybackRateCommand.supportedPlaybackRates == [1.0, 1.5, 2.0])
@@ -87,11 +81,46 @@ struct RemoteCommandControllerTests {
87
81
  #expect(center.playCommand.isEnabled)
88
82
  }
89
83
 
84
+ @Test func retainedCommandStaysEnabledWhenOthersAreAdded() {
85
+ let (controller, _) = makeController()
86
+ let center = MPRemoteCommandCenter.shared()
87
+
88
+ controller.enable(commands: [.play, .pause])
89
+ controller.enable(commands: [.play, .pause, .stop])
90
+
91
+ #expect(center.playCommand.isEnabled)
92
+ #expect(center.pauseCommand.isEnabled)
93
+ #expect(center.stopCommand.isEnabled)
94
+ }
95
+
96
+ @Test func droppedJumpCommandIsDisabled() {
97
+ let (controller, _) = makeController()
98
+ let center = MPRemoteCommandCenter.shared()
99
+
100
+ controller.enable(commands: [.play, .skipForward])
101
+ controller.enable(commands: [.play])
102
+
103
+ #expect(!center.skipForwardCommand.isEnabled)
104
+ #expect(center.playCommand.isEnabled)
105
+ }
106
+
107
+ @Test func enablingNoCommandsDisablesEveryOne() {
108
+ let (controller, _) = makeController()
109
+ let center = MPRemoteCommandCenter.shared()
110
+
111
+ controller.enable(commands: Set(RemoteCommand.allCases))
112
+ controller.enable(commands: [])
113
+
114
+ #expect(!center.playCommand.isEnabled)
115
+ #expect(!center.skipBackwardCommand.isEnabled)
116
+ #expect(!center.changePlaybackRateCommand.isEnabled)
117
+ }
118
+
90
119
  @Test func disableAllClearsEveryTarget() {
91
120
  let (controller, _) = makeController()
92
121
  let center = MPRemoteCommandCenter.shared()
93
122
 
94
- controller.enable(commands: [.play, .pause, .skipForward(preferredIntervals: [15])])
123
+ controller.enable(commands: [.play, .pause, .skipForward])
95
124
  controller.disableAll()
96
125
 
97
126
  #expect(!center.playCommand.isEnabled)
@@ -126,7 +155,7 @@ struct RemoteCommandControllerTests {
126
155
  weak var weakController: RemoteCommandController?
127
156
  autoreleasepool {
128
157
  let (controller, _) = makeController()
129
- controller.enable(commands: RemoteCommand.all())
158
+ controller.enable(commands: Set(RemoteCommand.allCases))
130
159
  controller.disableAll()
131
160
  weakController = controller
132
161
  }
@@ -109,6 +109,46 @@ final class MockPlayerEngine: PlayerEngine {
109
109
  func updateSkipAvailability(canNext: Bool, canPrevious: Bool) {
110
110
  updateSkipAvailabilityCalls.append((canNext, canPrevious))
111
111
  }
112
+
113
+ // Now-playing lines and the audio session, in the order the Player drove them
114
+ var nowPlayingRenderCount = 0
115
+ var nowPlayingTrackChangedCount = 0
116
+ var appliedTimedMetadata: [TimedMetadata] = []
117
+ var activateAudioSessionCount = 0
118
+ var cancelAudioSessionReleaseCount = 0
119
+ var scheduleAudioSessionReleaseCount = 0
120
+ /// Called with the name of each of these calls, so a test can interleave them with callbacks.
121
+ var onSeamCall: ((String) -> Void)?
122
+
123
+ func renderNowPlaying() {
124
+ nowPlayingRenderCount += 1
125
+ onSeamCall?("renderNowPlaying")
126
+ }
127
+
128
+ func nowPlayingTrackChanged() {
129
+ nowPlayingTrackChangedCount += 1
130
+ onSeamCall?("nowPlayingTrackChanged")
131
+ }
132
+
133
+ func applyTimedMetadata(_ metadata: TimedMetadata) {
134
+ appliedTimedMetadata.append(metadata)
135
+ onSeamCall?("applyTimedMetadata")
136
+ }
137
+
138
+ func activateAudioSession() {
139
+ activateAudioSessionCount += 1
140
+ onSeamCall?("activateAudioSession")
141
+ }
142
+
143
+ func cancelAudioSessionRelease() {
144
+ cancelAudioSessionReleaseCount += 1
145
+ onSeamCall?("cancelAudioSessionRelease")
146
+ }
147
+
148
+ func scheduleAudioSessionRelease() {
149
+ scheduleAudioSessionReleaseCount += 1
150
+ onSeamCall?("scheduleAudioSessionRelease")
151
+ }
112
152
  }
113
153
 
114
154
  /// A Player over `engine` whose retries reload while `retries` and otherwise give up, keeping
@@ -146,7 +186,7 @@ final class MockPlayerCallbacks: PlayerCallbacks {
146
186
  var shuffleEnabledChanges: [Bool] = []
147
187
  var errorEvents: [PlaybackErrorEvent] = []
148
188
  var playbackIntervalFiredCount = 0
149
- var releaseSessionCount = 0
189
+ var timedMetadata: [TimedMetadata] = []
150
190
 
151
191
  /// The order playback, intent and playing-state changes arrived in.
152
192
  var publishOrder: [String] = []
@@ -200,8 +240,9 @@ final class MockPlayerCallbacks: PlayerCallbacks {
200
240
  errorEvents.append(event)
201
241
  }
202
242
 
203
- func playerShouldReleaseSession() {
204
- releaseSessionCount += 1
243
+ func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata) {
244
+ publishOrder.append("timedMetadata")
245
+ timedMetadata.append(metadata)
205
246
  }
206
247
 
207
248
  var sleepTimerChanges: [SleepTimerState?] = []