react-native-audio-browser 0.2.0-next.33 → 0.2.0-next.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ios/AVPlayerEngine.swift +60 -24
- package/ios/AVPlayerEngineCallbacks.swift +0 -1
- package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
- package/ios/CarPlay/CarPlaySession.swift +3 -3
- package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
- package/ios/Extension/Capability+RemoteCommand.swift +22 -69
- package/ios/Extension/Track+AVPlayer.swift +0 -16
- package/ios/HybridAudioBrowser+Browser.swift +16 -17
- package/ios/HybridAudioBrowser+Options.swift +27 -66
- package/ios/HybridAudioBrowser+Player.swift +4 -60
- package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
- package/ios/HybridAudioBrowser.swift +36 -99
- package/ios/Model/NitroTypeStubs.swift +15 -0
- package/ios/Model/PlayerUpdateOptions.swift +2 -0
- package/ios/Model/RemoteCommand.swift +6 -78
- package/ios/Player/NowPlayingUpdater.swift +13 -7
- package/ios/Player/Player.swift +26 -10
- package/ios/Player/PlayerCallbacks.swift +1 -4
- package/ios/Player/PlayerEngine.swift +17 -0
- package/ios/Player/RetryLoop.swift +5 -1
- package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
- package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
- package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
- package/ios/Tests/EmitterTests.swift +3 -48
- package/ios/Tests/PlayerPolicyTests.swift +27 -3
- package/ios/Tests/PlayerTests.swift +95 -17
- package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
- package/ios/Tests/Support/TestTypes.swift +44 -3
- package/ios/TrackSelector.swift +34 -52
- package/ios/Util/Emitter.swift +0 -14
- package/ios/Util/NetworkMonitor.swift +8 -25
- package/ios/Util/OutputPicker.swift +48 -0
- package/package.json +1 -1
- package/ios/Model/SourceType.swift +0 -6
- package/ios/Tests/RemoteCommandTests.swift +0 -65
|
@@ -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(
|
|
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
|
|
|
@@ -39,7 +39,10 @@ final class RemoteCommandController {
|
|
|
39
39
|
|
|
40
40
|
weak var callbacks: RemoteCommandCallbacks?
|
|
41
41
|
|
|
42
|
-
private var enabledCommands:
|
|
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
|
-
|
|
68
|
+
commands.forEach(disable)
|
|
66
69
|
center = newCenter
|
|
67
|
-
enable
|
|
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
|
-
|
|
75
|
-
|
|
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
|
|
124
|
-
case
|
|
125
|
-
case
|
|
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
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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,126 @@ 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])
|
|
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.
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
204
|
-
|
|
243
|
+
func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata) {
|
|
244
|
+
publishOrder.append("timedMetadata")
|
|
245
|
+
timedMetadata.append(metadata)
|
|
205
246
|
}
|
|
206
247
|
|
|
207
248
|
var sleepTimerChanges: [SleepTimerState?] = []
|