react-native-audio-browser 0.2.0-next.33 → 0.2.0-next.35

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 (55) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/CarPlay/CPInterfaceController+Safe.swift +0 -7
  4. package/ios/CarPlay/CarPlayController.swift +0 -6
  5. package/ios/CarPlay/CarPlayImageLoader.swift +44 -97
  6. package/ios/CarPlay/CarPlayListItemFactory.swift +74 -99
  7. package/ios/CarPlay/CarPlayNowPlayingManager.swift +13 -46
  8. package/ios/CarPlay/CarPlaySession+Navigator.swift +2 -13
  9. package/ios/CarPlay/CarPlaySession+PageLoader.swift +12 -13
  10. package/ios/CarPlay/CarPlaySession+Refresher.swift +13 -13
  11. package/ios/CarPlay/CarPlaySession+TabBar.swift +22 -23
  12. package/ios/CarPlay/CarPlaySession.swift +10 -12
  13. package/ios/CarPlay/IntentHandlers.swift +96 -0
  14. package/ios/CarPlay/MediaIntentCriteria.swift +38 -80
  15. package/ios/CarPlay/{RNABMediaIntentHandler.swift → MediaIntentHandler.swift} +3 -6
  16. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  17. package/ios/CarPlay/NowPlayingButtonRules.swift +35 -0
  18. package/ios/CarPlay/RNABAudioBrowser.m +7 -15
  19. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  20. package/ios/Extension/Track+AVPlayer.swift +0 -16
  21. package/ios/HybridAudioBrowser+Browser.swift +16 -17
  22. package/ios/HybridAudioBrowser+Options.swift +27 -66
  23. package/ios/HybridAudioBrowser+Player.swift +4 -60
  24. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  25. package/ios/HybridAudioBrowser.swift +36 -99
  26. package/ios/Model/NitroTypeStubs.swift +15 -0
  27. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  28. package/ios/Model/RemoteCommand.swift +6 -78
  29. package/ios/Player/NowPlayingUpdater.swift +13 -7
  30. package/ios/Player/Player.swift +45 -10
  31. package/ios/Player/PlayerCallbacks.swift +1 -4
  32. package/ios/Player/PlayerEngine.swift +17 -0
  33. package/ios/Player/RetryLoop.swift +5 -1
  34. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  35. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  36. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  37. package/ios/Tests/ConcurrentMapTests.swift +42 -0
  38. package/ios/Tests/EmitterTests.swift +3 -48
  39. package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
  40. package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
  41. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  42. package/ios/Tests/PlayerTests.swift +136 -17
  43. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  44. package/ios/Tests/Support/TestTypes.swift +44 -3
  45. package/ios/TrackSelector.swift +34 -52
  46. package/ios/Util/ConcurrentMap.swift +19 -0
  47. package/ios/Util/Emitter.swift +0 -14
  48. package/ios/Util/NetworkMonitor.swift +8 -25
  49. package/ios/Util/OutputPicker.swift +48 -0
  50. package/package.json +1 -1
  51. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +0 -20
  52. package/ios/CarPlay/RNABMediaAddHandler.swift +0 -59
  53. package/ios/CarPlay/RNABMediaAffinityHandler.swift +0 -53
  54. package/ios/Model/SourceType.swift +0 -6
  55. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -2,93 +2,105 @@ import Testing
2
2
 
3
3
  @testable import AudioBrowserTestable
4
4
 
5
+ private func criteria(
6
+ mediaName: String? = nil,
7
+ genreNames: [String] = [],
8
+ artistName: String? = nil,
9
+ albumName: String? = nil,
10
+ mediaTypeMode: String? = nil,
11
+ reference: MediaIntentCriteria.Reference = .unknown,
12
+ hasMediaType: Bool = false,
13
+ appName: String? = "Acme Radio",
14
+ ) -> MediaIntentCriteria {
15
+ MediaIntentCriteria(
16
+ mediaName: mediaName, genreNames: genreNames, artistName: artistName,
17
+ albumName: albumName, mediaTypeMode: mediaTypeMode,
18
+ reference: reference, hasMediaType: hasMediaType, appName: appName,
19
+ )
20
+ }
21
+
5
22
  @Suite("MediaIntentCriteria")
6
23
  struct MediaIntentCriteriaTests {
7
24
  @Test func emptyEverything_isResume() {
8
- let c = MediaIntentCriteria(query: "", reference: .unknown, hasGenres: false, hasMediaType: false, matchesAppName: false)
9
- #expect(c.isResume)
25
+ #expect(criteria().isResume)
10
26
  }
11
27
 
12
28
  @Test func whitespaceQuery_isResume() {
13
- let c = MediaIntentCriteria(query: " ", reference: .unknown, hasGenres: false, hasMediaType: false, matchesAppName: false)
29
+ let c = criteria(mediaName: " ")
30
+ #expect(c.query == "")
14
31
  #expect(c.isResume)
15
32
  }
16
33
 
17
34
  // currentlyPlaying always resumes — "play this" makes the active track play.
18
35
  @Test func currentlyPlaying_isResume() {
19
- #expect(MediaIntentCriteria(query: "", reference: .currentlyPlaying, hasGenres: false, hasMediaType: false, matchesAppName: false).isResume)
36
+ #expect(criteria(reference: .currentlyPlaying).isResume)
20
37
  // even with a stray query/filter, "play this" is a resume
21
- #expect(MediaIntentCriteria(query: "jazz", reference: .currentlyPlaying, hasGenres: true, hasMediaType: true, matchesAppName: false).isResume)
38
+ #expect(criteria(mediaName: "jazz", genreNames: ["jazz"], reference: .currentlyPlaying, hasMediaType: true).isResume)
22
39
  }
23
40
 
24
41
  // .my always searches — "play my favorites" goes to the consumer, never resume.
25
42
  @Test func my_isNotResume() {
26
- #expect(!MediaIntentCriteria(query: "", reference: .my, hasGenres: false, hasMediaType: false, matchesAppName: false).isResume)
27
- #expect(!MediaIntentCriteria(query: "", reference: .my, hasGenres: false, hasMediaType: false, matchesAppName: true).isResume)
43
+ #expect(!criteria(reference: .my).isResume)
44
+ let namesApp = criteria(mediaName: "Acme", reference: .my)
45
+ #expect(namesApp.matchesAppName)
46
+ #expect(!namesApp.isResume)
28
47
  }
29
48
 
30
49
  @Test func anyCriteria_isNotResume() {
31
- #expect(!MediaIntentCriteria(query: "kcrw", reference: .unknown, hasGenres: false, hasMediaType: false, matchesAppName: false).isResume)
32
- #expect(!MediaIntentCriteria(query: "", reference: .unknown, hasGenres: true, hasMediaType: false, matchesAppName: false).isResume)
33
- #expect(!MediaIntentCriteria(query: "", reference: .unknown, hasGenres: false, hasMediaType: true, matchesAppName: false).isResume)
50
+ #expect(!criteria(mediaName: "kcrw").isResume)
51
+ #expect(!criteria(genreNames: ["jazz"]).isResume)
52
+ #expect(!criteria(hasMediaType: true).isResume)
34
53
  }
35
54
 
36
55
  @Test func queryNamesApp_isResume() {
37
- let c = MediaIntentCriteria(query: "Garden", reference: .unknown, hasGenres: false, hasMediaType: true, matchesAppName: true)
56
+ let c = criteria(mediaName: "Acme", hasMediaType: true)
57
+ #expect(c.matchesAppName)
38
58
  #expect(c.isResume)
39
59
  }
40
60
 
41
61
  @Test func queryNamesApp_withGenre_isNotResume() {
42
- #expect(!MediaIntentCriteria(query: "Garden", reference: .unknown, hasGenres: true, hasMediaType: false, matchesAppName: true).isResume)
62
+ let c = criteria(mediaName: "Acme", genreNames: ["jazz"])
63
+ #expect(c.matchesAppName)
64
+ #expect(!c.isResume)
43
65
  }
44
66
 
45
67
  @Test func queryNotApp_isNotResume() {
46
- #expect(!MediaIntentCriteria(query: "jazz", reference: .unknown, hasGenres: false, hasMediaType: false, matchesAppName: false).isResume)
68
+ #expect(!criteria(mediaName: "jazz").isResume)
47
69
  }
48
- }
49
70
 
50
- @Suite("MediaIntentCriteria.from")
51
- struct MediaIntentCriteriaFromTests {
52
- private func from(
53
- mediaName: String? = nil,
54
- genreNames: [String] = [],
55
- artistName: String? = nil,
56
- albumName: String? = nil,
57
- mediaTypeMode: String? = nil,
58
- reference: MediaIntentCriteria.Reference = .unknown,
59
- hasMediaType: Bool = false,
60
- appName: String? = "Radio Garden",
61
- ) -> MediaIntentCriteria {
62
- MediaIntentCriteria.from(
63
- mediaName: mediaName, genreNames: genreNames, artistName: artistName,
64
- albumName: albumName, mediaTypeMode: mediaTypeMode,
65
- reference: reference, hasMediaType: hasMediaType, appName: appName,
66
- )
71
+ // An empty genre name is still a genre filter: the fact is that genres were given.
72
+ @Test func emptyGenreName_isStillAGenreFilter() {
73
+ let c = criteria(genreNames: [""])
74
+ #expect(c.genre == "")
75
+ #expect(!c.isResume)
67
76
  }
77
+ }
68
78
 
79
+ @Suite("MediaIntentCriteria from intent fields")
80
+ struct MediaIntentCriteriaFromTests {
69
81
  // MARK: resume vs search
70
82
 
71
83
  @Test func playAppName_resumes() {
72
- let c = from(mediaName: "Garden", hasMediaType: true)
73
- #expect(c.query == "Garden")
84
+ let c = criteria(mediaName: "Acme", hasMediaType: true)
85
+ #expect(c.query == "Acme")
74
86
  #expect(c.matchesAppName)
75
87
  #expect(c.isResume)
76
88
  }
77
89
 
78
90
  @Test func emptyIntent_resumes() {
79
- let c = from()
91
+ let c = criteria()
80
92
  #expect(c.query == "")
81
93
  #expect(c.isResume)
82
94
  }
83
95
 
84
96
  @Test func playMyFavorites_searches() {
85
- let c = from(reference: .my)
97
+ let c = criteria(reference: .my)
86
98
  #expect(!c.isResume)
87
99
  #expect(c.reference == .my)
88
100
  }
89
101
 
90
102
  @Test func stationName_searches() {
91
- let c = from(mediaName: "KCRW")
103
+ let c = criteria(mediaName: "KCRW")
92
104
  #expect(c.query == "KCRW")
93
105
  #expect(!c.matchesAppName)
94
106
  #expect(!c.isResume)
@@ -99,23 +111,22 @@ struct MediaIntentCriteriaFromTests {
99
111
 
100
112
  // "Play jazz" → genre is a FILTER, not a mode. mode stays nil; genre set.
101
113
  @Test func genre_isFilterNotMode() {
102
- let c = from(genreNames: ["jazz"], hasMediaType: true)
114
+ let c = criteria(genreNames: ["jazz"], hasMediaType: true)
103
115
  #expect(c.query == "jazz")
104
116
  #expect(c.searchMode == nil)
105
117
  #expect(c.genre == "jazz")
106
- #expect(c.hasGenres)
107
118
  #expect(!c.isResume)
108
119
  }
109
120
 
110
121
  @Test func multiWordGenre_joined_noMode() {
111
- let c = from(mediaName: " ", genreNames: ["classic", "rock"])
122
+ let c = criteria(mediaName: " ", genreNames: ["classic", "rock"])
112
123
  #expect(c.query == "classic rock")
113
124
  #expect(c.genre == "classic rock")
114
125
  #expect(c.searchMode == nil)
115
126
  }
116
127
 
117
128
  @Test func artist_isFilterNotMode() {
118
- let c = from(artistName: "Michael Jackson")
129
+ let c = criteria(artistName: "Michael Jackson")
119
130
  #expect(c.query == "Michael Jackson")
120
131
  #expect(c.searchMode == nil)
121
132
  #expect(c.artist == "Michael Jackson")
@@ -123,7 +134,7 @@ struct MediaIntentCriteriaFromTests {
123
134
  }
124
135
 
125
136
  @Test func albumAndArtist_areFiltersNotMode() {
126
- let c = from(mediaName: "Thriller", artistName: "Michael Jackson", albumName: "Thriller")
137
+ let c = criteria(mediaName: "Thriller", artistName: "Michael Jackson", albumName: "Thriller")
127
138
  #expect(c.searchMode == nil)
128
139
  #expect(c.album == "Thriller")
129
140
  #expect(c.artist == "Michael Jackson")
@@ -132,7 +143,7 @@ struct MediaIntentCriteriaFromTests {
132
143
 
133
144
  // A vertical mediaType DOES set the mode, and travels with its filters.
134
145
  @Test func song_viaMediaTypeMode_setsTitle() {
135
- let c = from(mediaName: "Billie Jean", mediaTypeMode: "song")
146
+ let c = criteria(mediaName: "Billie Jean", mediaTypeMode: "song")
136
147
  #expect(c.query == "Billie Jean")
137
148
  #expect(c.searchMode == "song")
138
149
  #expect(c.title == "Billie Jean")
@@ -140,7 +151,7 @@ struct MediaIntentCriteriaFromTests {
140
151
  }
141
152
 
142
153
  @Test func playlist_viaMediaTypeMode_setsPlaylist() {
143
- let c = from(mediaName: "Workout", mediaTypeMode: "playlist")
154
+ let c = criteria(mediaName: "Workout", mediaTypeMode: "playlist")
144
155
  #expect(c.query == "Workout")
145
156
  #expect(c.searchMode == "playlist")
146
157
  #expect(c.playlist == "Workout")
@@ -149,19 +160,19 @@ struct MediaIntentCriteriaFromTests {
149
160
 
150
161
  // "Play jazz station" → vertical wins as mode; genre rides as a filter.
151
162
  @Test func station_withGenreFilter() {
152
- let c = from(genreNames: ["jazz"], mediaTypeMode: "station", hasMediaType: true)
163
+ let c = criteria(genreNames: ["jazz"], mediaTypeMode: "station", hasMediaType: true)
153
164
  #expect(c.searchMode == "station")
154
165
  #expect(c.genre == "jazz")
155
166
  }
156
167
 
157
168
  @Test func podcast_setsMode() {
158
- let c = from(mediaName: "Serial", mediaTypeMode: "podcast", hasMediaType: true)
169
+ let c = criteria(mediaName: "Serial", mediaTypeMode: "podcast", hasMediaType: true)
159
170
  #expect(c.searchMode == "podcast")
160
171
  #expect(c.query == "Serial")
161
172
  }
162
173
 
163
174
  @Test func plainName_hasNoStructuredFields() {
164
- let c = from(mediaName: "KCRW")
175
+ let c = criteria(mediaName: "KCRW")
165
176
  #expect(c.searchMode == nil)
166
177
  #expect(c.genre == nil)
167
178
  #expect(c.artist == nil)
@@ -0,0 +1,43 @@
1
+ @testable import AudioBrowserTestable
2
+ import Testing
3
+
4
+ @Suite("NowPlayingButtonRules")
5
+ struct NowPlayingButtonRulesTests {
6
+ @Test func buttons_dropRepeats_keepingFirstOrder() {
7
+ #expect(NowPlayingButtonRules.buttons(["shuffle", "favorite", "shuffle", "repeat"]) == ["shuffle", "favorite", "repeat"])
8
+ }
9
+
10
+ @Test func buttons_capAtFive_afterDroppingRepeats() {
11
+ let configured = ["a", "a", "b", "c", "d", "e", "f"]
12
+ #expect(NowPlayingButtonRules.buttons(configured) == ["a", "b", "c", "d", "e"])
13
+ }
14
+
15
+ @Test func buttons_empty() {
16
+ #expect(NowPlayingButtonRules.buttons([String]()).isEmpty)
17
+ }
18
+
19
+ @Test func repeatMode_cyclesOffTrackQueue() {
20
+ #expect(NowPlayingButtonRules.repeatMode(after: .off) == .track)
21
+ #expect(NowPlayingButtonRules.repeatMode(after: .track) == .queue)
22
+ #expect(NowPlayingButtonRules.repeatMode(after: .queue) == .off)
23
+ }
24
+
25
+ @Test func rate_advancesFromAConfiguredRate_andWraps() {
26
+ let rates = [0.5, 1, 1.5, 2]
27
+ #expect(NowPlayingButtonRules.rate(after: 1, in: rates) == 1.5)
28
+ #expect(NowPlayingButtonRules.rate(after: 2, in: rates) == 0.5)
29
+ }
30
+
31
+ @Test func rate_matchesAConfiguredRateWithinAHundredth() {
32
+ #expect(NowPlayingButtonRules.rate(after: 1.005, in: [1, 1.5]) == 1.5)
33
+ }
34
+
35
+ @Test func rate_offTheList_takesTheFirstAboveIt_elseTheFirst() {
36
+ #expect(NowPlayingButtonRules.rate(after: 1.2, in: [0.5, 1, 1.5]) == 1.5)
37
+ #expect(NowPlayingButtonRules.rate(after: 3, in: [0.5, 1, 1.5]) == 0.5)
38
+ }
39
+
40
+ @Test func rate_noRates_isNil() {
41
+ #expect(NowPlayingButtonRules.rate(after: 1, in: []) == nil)
42
+ }
43
+ }
@@ -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,167 @@ 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])
1396
+ }
1397
+ }
1398
+
1399
+ @Suite("Player - now-playing preview")
1400
+ struct NowPlayingPreviewTests {
1401
+ @Test @MainActor
1402
+ func preview_showsTheTappedTrackWithoutLoadingIt() {
1403
+ let (c, eh, _, _) = makeCoordinator()
1404
+ let tapped = Track(id: "tapped", src: "https://example.com/tapped.mp3", title: "Tapped")
1405
+
1406
+ c.previewNowPlaying(tapped)
1407
+
1408
+ #expect(eh.loadNowPlayingMetadataCalls.map(\.id) == ["tapped"])
1409
+ #expect(eh.resolveAndLoadCalls.isEmpty)
1410
+ #expect(c.currentTrack == nil)
1411
+ }
1412
+
1413
+ @Test @MainActor
1414
+ func endingPreview_restoresTheCurrentTrackAndRendersItsLines() {
1415
+ let (c, eh, _, _) = makeCoordinator()
1416
+ loadTrack(c, id: "current")
1417
+ c.previewNowPlaying(Track(id: "tapped", src: "https://example.com/tapped.mp3", title: "Tapped"))
1418
+ let rendersBefore = eh.nowPlayingRenderCount
1419
+
1420
+ c.endNowPlayingPreview()
1421
+
1422
+ #expect(eh.loadNowPlayingMetadataCalls.last?.id == "current")
1423
+ #expect(eh.nowPlayingRenderCount == rendersBefore + 1)
1424
+ #expect(eh.clearNowPlayingCallCount == 0)
1425
+ }
1426
+
1427
+ @Test @MainActor
1428
+ func endingPreview_withNothingCurrent_clearsTheSurface() {
1429
+ let (c, eh, _, _) = makeCoordinator()
1430
+ c.previewNowPlaying(Track(id: "tapped", src: "https://example.com/tapped.mp3", title: "Tapped"))
1431
+
1432
+ c.endNowPlayingPreview()
1433
+
1434
+ #expect(eh.clearNowPlayingCallCount == 1)
1435
+ #expect(eh.loadNowPlayingMetadataCalls.map(\.id) == ["tapped"])
1436
+ #expect(eh.nowPlayingRenderCount == 0)
1318
1437
  }
1319
1438
  }
@@ -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
  }