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
|
@@ -1,89 +1,17 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
// consumers of the header (Xcode 26.2 / Swift 6.2).
|
|
7
|
-
enum RemoteCommand: CustomStringConvertible, Equatable {
|
|
3
|
+
/// A system remote command (lock screen, Control Center, CarPlay, headset). The skip intervals
|
|
4
|
+
/// and the playback rates the commands offer go to `RemoteCommandController.enable` beside them.
|
|
5
|
+
enum RemoteCommand: CaseIterable, Hashable {
|
|
8
6
|
case play
|
|
9
|
-
|
|
10
7
|
case pause
|
|
11
|
-
|
|
12
8
|
case stop
|
|
13
|
-
|
|
14
|
-
case togglePlayPause
|
|
15
|
-
|
|
16
9
|
case next
|
|
17
|
-
|
|
18
10
|
case previous
|
|
19
|
-
|
|
20
11
|
case changePlaybackPosition
|
|
21
|
-
|
|
22
|
-
case
|
|
23
|
-
|
|
24
|
-
case skipBackward(preferredIntervals: [NSNumber])
|
|
25
|
-
|
|
12
|
+
case skipForward
|
|
13
|
+
case skipBackward
|
|
26
14
|
case changeRepeatMode
|
|
27
|
-
|
|
28
15
|
case changeShuffleMode
|
|
29
|
-
|
|
30
|
-
case changePlaybackRate(supportedPlaybackRates: [NSNumber])
|
|
31
|
-
|
|
32
|
-
var description: String {
|
|
33
|
-
switch self {
|
|
34
|
-
case .play: "play"
|
|
35
|
-
case .pause: "pause"
|
|
36
|
-
case .stop: "stop"
|
|
37
|
-
case .togglePlayPause: "togglePlayPause"
|
|
38
|
-
case .next: "nextTrack"
|
|
39
|
-
case .previous: "previousTrack"
|
|
40
|
-
case .changePlaybackPosition: "changePlaybackPosition"
|
|
41
|
-
case .skipForward: "skipForward"
|
|
42
|
-
case .skipBackward: "skipBackward"
|
|
43
|
-
case .changeRepeatMode: "changeRepeatMode"
|
|
44
|
-
case .changeShuffleMode: "changeShuffleMode"
|
|
45
|
-
case .changePlaybackRate: "changePlaybackRate"
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
var key: String { description }
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
Commands in `self` that are absent from `next` and so should be disabled.
|
|
53
|
-
|
|
54
|
-
Compared by `key`, not by `==`. `RemoteCommand` is `Equatable` including its
|
|
55
|
-
associated values, but every case with associated values maps onto a single
|
|
56
|
-
`MPRemoteCommand` addressed by `key`: `.skipForward([15])` and
|
|
57
|
-
`.skipForward([30])` are two different values of one command. Diffing by `==`
|
|
58
|
-
would report the old interval as removed and disable the command that the new
|
|
59
|
-
interval had just re-enabled.
|
|
60
|
-
*/
|
|
61
|
-
static func commandsToDisable(
|
|
62
|
-
enabled: [RemoteCommand],
|
|
63
|
-
replacedBy next: [RemoteCommand],
|
|
64
|
-
) -> [RemoteCommand] {
|
|
65
|
-
let nextKeys = Set(next.map(\.key))
|
|
66
|
-
return enabled.filter { !nextKeys.contains($0.key) }
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
All values in an array for convenience.
|
|
71
|
-
Don't use for associated values.
|
|
72
|
-
*/
|
|
73
|
-
static func all() -> [RemoteCommand] {
|
|
74
|
-
[
|
|
75
|
-
.play,
|
|
76
|
-
.pause,
|
|
77
|
-
.stop,
|
|
78
|
-
.togglePlayPause,
|
|
79
|
-
.next,
|
|
80
|
-
.previous,
|
|
81
|
-
.changePlaybackPosition,
|
|
82
|
-
.skipForward(preferredIntervals: []),
|
|
83
|
-
.skipBackward(preferredIntervals: []),
|
|
84
|
-
.changeRepeatMode,
|
|
85
|
-
.changeShuffleMode,
|
|
86
|
-
.changePlaybackRate(supportedPlaybackRates: []),
|
|
87
|
-
]
|
|
88
|
-
}
|
|
16
|
+
case changePlaybackRate
|
|
89
17
|
}
|
|
@@ -18,13 +18,11 @@ final class NowPlayingUpdater {
|
|
|
18
18
|
var onChanged: ((NowPlayingMetadata) -> Void)?
|
|
19
19
|
|
|
20
20
|
/// When false the surface shows the track's own fields: the override and formatter are ignored.
|
|
21
|
-
|
|
21
|
+
private let enabled: Bool
|
|
22
22
|
var formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
|
|
23
23
|
|
|
24
|
-
///
|
|
25
|
-
var
|
|
26
|
-
didSet { if isOnline != oldValue { render() } }
|
|
27
|
-
}
|
|
24
|
+
/// Tells a stall apart: offline or buffering.
|
|
25
|
+
private weak var networkStatus: (any NetworkStatusProviding)?
|
|
28
26
|
|
|
29
27
|
private var override: NowPlayingUpdate?
|
|
30
28
|
private var timedMetadata: TimedMetadata?
|
|
@@ -103,8 +101,16 @@ final class NowPlayingUpdater {
|
|
|
103
101
|
/// formatter result (a flash overwrites it, `clearNowPlaying` empties it).
|
|
104
102
|
private var formattedTrackId: String?
|
|
105
103
|
|
|
106
|
-
init(
|
|
104
|
+
init(
|
|
105
|
+
nowPlayingInfoController: NowPlayingInfoController,
|
|
106
|
+
networkStatus: (any NetworkStatusProviding)?,
|
|
107
|
+
enabled: Bool,
|
|
108
|
+
formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
|
|
109
|
+
) {
|
|
107
110
|
self.nowPlayingInfoController = nowPlayingInfoController
|
|
111
|
+
self.networkStatus = networkStatus
|
|
112
|
+
self.enabled = enabled
|
|
113
|
+
self.formatter = formatter
|
|
108
114
|
}
|
|
109
115
|
|
|
110
116
|
/// Pixel width to request now-playing artwork at: the active scene's screen
|
|
@@ -169,7 +175,7 @@ final class NowPlayingUpdater {
|
|
|
169
175
|
track: track,
|
|
170
176
|
timedMetadata: timedMetadata,
|
|
171
177
|
playWhenReady: source.playWhenReady,
|
|
172
|
-
stalled: source.isStalled ? (isOnline ? .buffering : .offline) : nil,
|
|
178
|
+
stalled: source.isStalled ? ((networkStatus?.isOnline ?? true) ? .buffering : .offline) : nil,
|
|
173
179
|
error: source.nitroPlaybackError,
|
|
174
180
|
)
|
|
175
181
|
// `@Sendable` breaks the @MainActor isolation inheritance: Nitro resolves the promise on the JS
|
package/ios/Player/Player.swift
CHANGED
|
@@ -216,12 +216,14 @@ final class Player {
|
|
|
216
216
|
/// - persistenceSaveInterval: How often the snapshot is saved while the play intent is active.
|
|
217
217
|
init(
|
|
218
218
|
engine: any PlayerEngine,
|
|
219
|
+
callbacks: PlayerCallbacks? = nil,
|
|
219
220
|
retry: RetryLoop = RetryLoop(),
|
|
220
221
|
sleepTimerScheduler: any SleepTimerScheduling = MainQueueSleepTimerScheduler(),
|
|
221
222
|
persistenceDefaults: UserDefaults = PlaybackPersistence.standardDefaults,
|
|
222
223
|
persistenceSaveInterval: Duration = .seconds(5),
|
|
223
224
|
) {
|
|
224
225
|
self.engine = engine
|
|
226
|
+
self.callbacks = callbacks
|
|
225
227
|
self.retry = retry
|
|
226
228
|
sleepTimerManager = SleepTimerManager(
|
|
227
229
|
fader: VolumeFader(
|
|
@@ -290,7 +292,8 @@ final class Player {
|
|
|
290
292
|
|
|
291
293
|
/// Emits what changed since the last publish, in order: the play intent, the playing state,
|
|
292
294
|
/// the playback (state and error), the terminal error, the active play intent. The one place
|
|
293
|
-
/// these leave the Player, so each goes out once per change
|
|
295
|
+
/// these leave the Player, so each goes out once per change, with the audio-session and
|
|
296
|
+
/// now-playing effects it drives.
|
|
294
297
|
private func publish() {
|
|
295
298
|
let playing = playingState
|
|
296
299
|
let now = Published(
|
|
@@ -302,13 +305,19 @@ final class Player {
|
|
|
302
305
|
guard now != old else { return }
|
|
303
306
|
published = now
|
|
304
307
|
if now.playWhenReady != old.playWhenReady {
|
|
308
|
+
if now.playWhenReady { engine.cancelAudioSessionRelease() }
|
|
305
309
|
callbacks?.playerDidChangePlayWhenReady(now.playWhenReady)
|
|
306
310
|
}
|
|
307
311
|
if now.playing != old.playing || now.buffering != old.buffering {
|
|
308
312
|
callbacks?.playerDidChangePlayingState(playing)
|
|
313
|
+
// Activated on output, not at setup: activating at launch interrupts other apps' audio.
|
|
314
|
+
if playing.playing || playing.buffering { engine.activateAudioSession() }
|
|
315
|
+
// A stall starting or recovering may leave the state as it was.
|
|
316
|
+
engine.renderNowPlaying()
|
|
309
317
|
}
|
|
310
318
|
if now.state != old.state || now.errorStatus != old.errorStatus {
|
|
311
319
|
callbacks?.playerDidChangePlayback(Playback(state: now.state, error: now.errorStatus.nitroError))
|
|
320
|
+
engine.renderNowPlaying()
|
|
312
321
|
}
|
|
313
322
|
let erred = now.state == .error, wasErred = old.state == .error
|
|
314
323
|
if erred != wasErred || (erred && now.errorStatus != old.errorStatus) {
|
|
@@ -399,13 +408,14 @@ final class Player {
|
|
|
399
408
|
}
|
|
400
409
|
|
|
401
410
|
/// Requests a session release once playback is deliberately stopped: no intent, no
|
|
402
|
-
/// interruption to resume from, no retry pending
|
|
411
|
+
/// interruption to resume from, and no retry pending, whose reconnect needs the session. The
|
|
412
|
+
/// engine debounces it.
|
|
403
413
|
private func evaluateSessionRelease() {
|
|
404
414
|
guard !playWhenReady,
|
|
405
415
|
!shouldResumeAfterInterruption,
|
|
406
416
|
retry.pendingTask == nil
|
|
407
417
|
else { return }
|
|
408
|
-
|
|
418
|
+
engine.scheduleAudioSessionRelease()
|
|
409
419
|
}
|
|
410
420
|
|
|
411
421
|
func getPlayback() -> Playback {
|
|
@@ -599,6 +609,7 @@ final class Player {
|
|
|
599
609
|
track: currentTrack,
|
|
600
610
|
)
|
|
601
611
|
callbacks?.playerDidChangeActiveTrack(eventData)
|
|
612
|
+
engine.nowPlayingTrackChanged()
|
|
602
613
|
lastTrack = currentTrack
|
|
603
614
|
lastIndex = queue.currentIndex
|
|
604
615
|
}
|
|
@@ -755,6 +766,9 @@ final class Player {
|
|
|
755
766
|
case .stalled:
|
|
756
767
|
// Nudge, don't reconnect: a genuine drop is recovered by the retry and network paths.
|
|
757
768
|
recoverFromStall(reconnectIfLive: false)
|
|
769
|
+
case let .timedMetadata(metadata):
|
|
770
|
+
callbacks?.playerDidReceiveTimedMetadata(metadata)
|
|
771
|
+
engine.applyTimedMetadata(metadata)
|
|
758
772
|
}
|
|
759
773
|
}
|
|
760
774
|
|
|
@@ -839,13 +853,15 @@ final class Player {
|
|
|
839
853
|
|
|
840
854
|
// MARK: - Connectivity
|
|
841
855
|
|
|
842
|
-
/// Re-
|
|
843
|
-
///
|
|
844
|
-
///
|
|
845
|
-
///
|
|
846
|
-
/// which owns
|
|
847
|
-
|
|
848
|
-
|
|
856
|
+
/// Re-renders the now-playing lines, whose stall line tells offline from buffering, and when
|
|
857
|
+
/// connectivity returns re-establishes a stalled stream. AVPlayer doesn't reliably report a
|
|
858
|
+
/// live stream's mid-stream drop as a failure: it waits with no error, so the retry loop never
|
|
859
|
+
/// engages. Only while stalled (.buffering) with play intent, so a pause or a retry that gave up
|
|
860
|
+
/// isn't resumed; and not while the retry loop is itself waiting for the network, which owns
|
|
861
|
+
/// that reload.
|
|
862
|
+
func networkStatusDidChange(isOnline: Bool) {
|
|
863
|
+
engine.renderNowPlaying()
|
|
864
|
+
guard isOnline, playWhenReady, state == .buffering, !retry.isWaitingForNetwork else { return }
|
|
849
865
|
logger.info("Connectivity restored while stalled — re-resolving to reconnect")
|
|
850
866
|
reloadResolving(startFromCurrentTime: true)
|
|
851
867
|
}
|
|
@@ -911,6 +927,25 @@ final class Player {
|
|
|
911
927
|
}
|
|
912
928
|
}
|
|
913
929
|
|
|
930
|
+
// MARK: - Now Playing Preview
|
|
931
|
+
|
|
932
|
+
/// Shows `track` on the now-playing surface before it is current, so a tapped track shows at
|
|
933
|
+
/// once while its queue and media resolve.
|
|
934
|
+
func previewNowPlaying(_ track: Track) {
|
|
935
|
+
engine.loadNowPlayingMetadata(for: track)
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/// Puts the current track back over a preview that will not play, or clears the surface when
|
|
939
|
+
/// nothing is current.
|
|
940
|
+
func endNowPlayingPreview() {
|
|
941
|
+
guard let currentTrack else {
|
|
942
|
+
engine.clearNowPlaying()
|
|
943
|
+
return
|
|
944
|
+
}
|
|
945
|
+
engine.loadNowPlayingMetadata(for: currentTrack)
|
|
946
|
+
engine.renderNowPlaying()
|
|
947
|
+
}
|
|
948
|
+
|
|
914
949
|
// MARK: - Progress Updates
|
|
915
950
|
|
|
916
951
|
func setProgressUpdateInterval(_ interval: TimeInterval?) {
|
|
@@ -15,9 +15,6 @@
|
|
|
15
15
|
func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent)
|
|
16
16
|
func playerDidChangeShuffleEnabled(_ enabled: Bool)
|
|
17
17
|
func playerDidError(_ event: PlaybackErrorEvent)
|
|
18
|
-
|
|
19
|
-
/// (those resume), and no retry is pending (a live-stream reconnect must keep the session). The
|
|
20
|
-
/// host may release the audio session (debounced) so other apps' audio can resume.
|
|
21
|
-
func playerShouldReleaseSession()
|
|
18
|
+
func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata)
|
|
22
19
|
func playerDidChangeSleepTimer(_ state: SleepTimerState?)
|
|
23
20
|
}
|
|
@@ -24,6 +24,9 @@ enum EngineEvent {
|
|
|
24
24
|
case playedToEnd
|
|
25
25
|
/// The buffer emptied mid-playback.
|
|
26
26
|
case stalled
|
|
27
|
+
/// A timed-metadata group (a live stream's song line), decoded with the loaded track's ICY
|
|
28
|
+
/// charset.
|
|
29
|
+
case timedMetadata(TimedMetadata)
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
/// The media stack under the Player: the AVPlayer and its items, media loading, now playing and
|
|
@@ -80,8 +83,22 @@ enum EngineEvent {
|
|
|
80
83
|
func primeNowPlaying()
|
|
81
84
|
func loadNowPlayingMetadata(for track: Track)
|
|
82
85
|
func clearNowPlaying()
|
|
86
|
+
/// Re-renders the now-playing lines from the Player's state.
|
|
87
|
+
func renderNowPlaying()
|
|
88
|
+
/// Drops the previous track's override, flash and timed metadata, and re-renders.
|
|
89
|
+
func nowPlayingTrackChanged()
|
|
90
|
+
func applyTimedMetadata(_ metadata: TimedMetadata)
|
|
83
91
|
func updateRemoteRepeatMode(_ mode: RepeatMode)
|
|
84
92
|
func updateRemoteShuffleMode(_ enabled: Bool)
|
|
85
93
|
/// Greys out next/previous on the remote surfaces when the queue has nowhere to skip.
|
|
86
94
|
func updateSkipAvailability(canNext: Bool, canPrevious: Bool)
|
|
95
|
+
|
|
96
|
+
// MARK: Audio session
|
|
97
|
+
|
|
98
|
+
/// Activates the audio session off the main thread, then re-requests the now-playing session,
|
|
99
|
+
/// which the system may have declined while the audio session was inactive.
|
|
100
|
+
func activateAudioSession()
|
|
101
|
+
func cancelAudioSessionRelease()
|
|
102
|
+
/// Releases the audio session after a debounce, unless playback resumes first.
|
|
103
|
+
func scheduleAudioSessionRelease()
|
|
87
104
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@testable import AudioBrowserTestable
|
|
2
|
+
import Testing
|
|
3
|
+
|
|
4
|
+
@Suite("concurrentMap")
|
|
5
|
+
struct ConcurrentMapTests {
|
|
6
|
+
@Test @MainActor
|
|
7
|
+
func keepsElementOrder_whenLaterElementsFinishFirst() async {
|
|
8
|
+
let results = await [30, 20, 10].concurrentMap { delayMs in
|
|
9
|
+
try? await Task.sleep(for: .milliseconds(delayMs))
|
|
10
|
+
return delayMs * 2
|
|
11
|
+
}
|
|
12
|
+
#expect(results == [60, 40, 20])
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test @MainActor
|
|
16
|
+
func keepsNilResults_inPlace() async {
|
|
17
|
+
let results = await ["a", "", "c"].concurrentMap { $0.isEmpty ? nil : $0 }
|
|
18
|
+
#expect(results == ["a", nil, "c"])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test @MainActor
|
|
22
|
+
func empty_returnsEmpty() async {
|
|
23
|
+
let results = await [Int]().concurrentMap { $0 }
|
|
24
|
+
#expect(results.isEmpty)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Test @MainActor
|
|
28
|
+
func cancellingTheCaller_cancelsEveryTransform() async {
|
|
29
|
+
let task = Task { @MainActor in
|
|
30
|
+
await [1, 2].concurrentMap { _ in
|
|
31
|
+
do {
|
|
32
|
+
try await Task.sleep(for: .seconds(10))
|
|
33
|
+
return false
|
|
34
|
+
} catch {
|
|
35
|
+
return Task.isCancelled
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
task.cancel()
|
|
40
|
+
#expect(await task.value == [true, true])
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -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
|
}
|