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.
- package/ios/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- package/ios/CarPlay/CPInterfaceController+Safe.swift +0 -7
- package/ios/CarPlay/CarPlayController.swift +0 -6
- package/ios/CarPlay/CarPlayImageLoader.swift +44 -97
- package/ios/CarPlay/CarPlayListItemFactory.swift +74 -99
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +13 -46
- package/ios/CarPlay/CarPlaySession+Navigator.swift +2 -13
- package/ios/CarPlay/CarPlaySession+PageLoader.swift +12 -13
- package/ios/CarPlay/CarPlaySession+Refresher.swift +13 -13
- package/ios/CarPlay/CarPlaySession+TabBar.swift +22 -23
- package/ios/CarPlay/CarPlaySession.swift +10 -12
- package/ios/CarPlay/IntentHandlers.swift +96 -0
- package/ios/CarPlay/MediaIntentCriteria.swift +38 -80
- package/ios/CarPlay/{RNABMediaIntentHandler.swift → MediaIntentHandler.swift} +3 -6
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- package/ios/CarPlay/NowPlayingButtonRules.swift +35 -0
- package/ios/CarPlay/RNABAudioBrowser.m +7 -15
- 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 +45 -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/ConcurrentMapTests.swift +42 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
- package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +136 -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/ConcurrentMap.swift +19 -0
- 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/CarPlay/HybridAudioBrowser+IntentFavorite.swift +0 -20
- package/ios/CarPlay/RNABMediaAddHandler.swift +0 -59
- package/ios/CarPlay/RNABMediaAffinityHandler.swift +0 -53
- package/ios/Model/SourceType.swift +0 -6
- 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
|
-
|
|
9
|
-
#expect(c.isResume)
|
|
25
|
+
#expect(criteria().isResume)
|
|
10
26
|
}
|
|
11
27
|
|
|
12
28
|
@Test func whitespaceQuery_isResume() {
|
|
13
|
-
let c =
|
|
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(
|
|
36
|
+
#expect(criteria(reference: .currentlyPlaying).isResume)
|
|
20
37
|
// even with a stray query/filter, "play this" is a resume
|
|
21
|
-
#expect(
|
|
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(!
|
|
27
|
-
|
|
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(!
|
|
32
|
-
#expect(!
|
|
33
|
-
#expect(!
|
|
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 =
|
|
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
|
-
|
|
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(!
|
|
68
|
+
#expect(!criteria(mediaName: "jazz").isResume)
|
|
47
69
|
}
|
|
48
|
-
}
|
|
49
70
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =
|
|
73
|
-
#expect(c.query == "
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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.
|
|
186
|
+
player.networkStatusDidChange(isOnline: true)
|
|
187
187
|
player.pause()
|
|
188
|
-
player.
|
|
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,
|
|
684
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
685
685
|
startPlaying(c, eh)
|
|
686
|
-
|
|
686
|
+
eh.scheduleAudioSessionReleaseCount = 0
|
|
687
687
|
|
|
688
688
|
eh.emit(.playedToEnd)
|
|
689
689
|
|
|
690
|
-
#expect(
|
|
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,
|
|
1275
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1276
1276
|
startPlaying(c, eh)
|
|
1277
|
-
|
|
1277
|
+
eh.scheduleAudioSessionReleaseCount = 0
|
|
1278
1278
|
c.pause()
|
|
1279
|
-
#expect(
|
|
1279
|
+
#expect(eh.scheduleAudioSessionReleaseCount == 1)
|
|
1280
1280
|
}
|
|
1281
1281
|
|
|
1282
1282
|
@Test @MainActor
|
|
1283
1283
|
func play_doesNotRequestRelease() {
|
|
1284
|
-
let (c, eh,
|
|
1284
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1285
1285
|
startPlaying(c, eh)
|
|
1286
|
-
#expect(
|
|
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,
|
|
1293
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1294
1294
|
startPlaying(c, eh)
|
|
1295
|
-
|
|
1295
|
+
eh.scheduleAudioSessionReleaseCount = 0
|
|
1296
1296
|
c.handleInterruptionBegan()
|
|
1297
|
-
#expect(
|
|
1297
|
+
#expect(eh.scheduleAudioSessionReleaseCount == 0)
|
|
1298
1298
|
}
|
|
1299
1299
|
|
|
1300
1300
|
@Test @MainActor
|
|
1301
1301
|
func interruptionEnded_withResume_doesNotRequestRelease() {
|
|
1302
|
-
let (c, eh,
|
|
1302
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1303
1303
|
startPlaying(c, eh)
|
|
1304
1304
|
c.handleInterruptionBegan()
|
|
1305
|
-
|
|
1305
|
+
eh.scheduleAudioSessionReleaseCount = 0
|
|
1306
1306
|
c.handleInterruptionEnded(shouldResume: true)
|
|
1307
|
-
#expect(
|
|
1307
|
+
#expect(eh.scheduleAudioSessionReleaseCount == 0)
|
|
1308
1308
|
}
|
|
1309
1309
|
|
|
1310
1310
|
@Test @MainActor
|
|
1311
1311
|
func interruptionEnded_withoutResume_requestsRelease() {
|
|
1312
|
-
let (c, eh,
|
|
1312
|
+
let (c, eh, _, _) = makeCoordinator()
|
|
1313
1313
|
startPlaying(c, eh)
|
|
1314
1314
|
c.handleInterruptionBegan()
|
|
1315
|
-
|
|
1315
|
+
eh.scheduleAudioSessionReleaseCount = 0
|
|
1316
1316
|
c.handleInterruptionEnded(shouldResume: false)
|
|
1317
|
-
#expect(
|
|
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.
|
|
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(
|
|
57
|
-
.play, .skipForward
|
|
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
|
|
71
|
-
controller.enable(commands: [.changePlaybackRate
|
|
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
|
|
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.
|
|
158
|
+
controller.enable(commands: Set(RemoteCommand.allCases))
|
|
130
159
|
controller.disableAll()
|
|
131
160
|
weakController = controller
|
|
132
161
|
}
|