react-native-audio-browser 0.2.0-next.32 → 0.2.0-next.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/Browser/BrowserConfig.swift +0 -5
  4. package/ios/Browser/BrowserError.swift +16 -0
  5. package/ios/Browser/BrowserManager.swift +36 -141
  6. package/ios/Browser/BrowserPathHelper.swift +25 -12
  7. package/ios/Browser/FavoriteSet.swift +46 -0
  8. package/ios/Browser/JsonModels.swift +79 -199
  9. package/ios/Browser/PageResolver.swift +16 -96
  10. package/ios/Browser/RequestLayer.swift +97 -2
  11. package/ios/Browser/RequestPipeline.swift +78 -227
  12. package/ios/Browser/SectionScope.swift +25 -0
  13. package/ios/Browser/SimpleRouter.swift +73 -144
  14. package/ios/CarPlay/CarPlayNowPlayingManager.swift +4 -4
  15. package/ios/CarPlay/CarPlaySession.swift +3 -3
  16. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  17. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  18. package/ios/Extension/SearchParams+Query.swift +28 -0
  19. package/ios/Extension/Track+AVPlayer.swift +0 -16
  20. package/ios/Extension/Track+Copying.swift +3 -1
  21. package/ios/Extension/Track+Identity.swift +7 -0
  22. package/ios/Http/HttpClient.swift +12 -30
  23. package/ios/HybridAudioBrowser+Browser.swift +21 -38
  24. package/ios/HybridAudioBrowser+Options.swift +27 -66
  25. package/ios/HybridAudioBrowser+Player.swift +4 -60
  26. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  27. package/ios/HybridAudioBrowser.swift +36 -99
  28. package/ios/Model/NitroTypeStubs.swift +80 -0
  29. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  30. package/ios/Model/RemoteCommand.swift +6 -78
  31. package/ios/Player/NowPlayingUpdater.swift +13 -7
  32. package/ios/Player/Player.swift +26 -10
  33. package/ios/Player/PlayerCallbacks.swift +1 -4
  34. package/ios/Player/PlayerEngine.swift +17 -0
  35. package/ios/Player/RetryLoop.swift +5 -1
  36. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  37. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  38. package/ios/Tests/BrowserPathHelperTests.swift +33 -0
  39. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  40. package/ios/Tests/EmitterTests.swift +3 -48
  41. package/ios/Tests/FavoriteSetTests.swift +60 -0
  42. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  43. package/ios/Tests/PlayerTests.swift +95 -17
  44. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  45. package/ios/Tests/RequestLayerTests.swift +116 -0
  46. package/ios/Tests/SearchParamsQueryTests.swift +48 -0
  47. package/ios/Tests/SectionScopeTests.swift +53 -0
  48. package/ios/Tests/SimpleRouterTests.swift +38 -3
  49. package/ios/Tests/Support/TestTypes.swift +44 -3
  50. package/ios/TrackSelector.swift +34 -52
  51. package/ios/Util/Emitter.swift +0 -14
  52. package/ios/Util/NetworkMonitor.swift +8 -25
  53. package/ios/Util/OutputPicker.swift +48 -0
  54. package/package.json +1 -1
  55. package/ios/Model/SourceType.swift +0 -6
  56. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -58,6 +58,21 @@
58
58
  var album: String?
59
59
  }
60
60
 
61
+ struct PlayerCapabilities: Equatable {
62
+ var play: Bool?
63
+ var pause: Bool?
64
+ var stop: Bool?
65
+ var seekTo: Bool?
66
+ var skipToNext: Bool?
67
+ var skipToPrevious: Bool?
68
+ var jumpForward: Bool?
69
+ var jumpBackward: Bool?
70
+ var favorite: Bool?
71
+ var shuffleMode: Bool?
72
+ var repeatMode: Bool?
73
+ var playbackRate: Bool?
74
+ }
75
+
61
76
  struct TrackLoadEvent {
62
77
  var track: Track
63
78
  var queue: [Track]
@@ -110,6 +125,71 @@
110
125
  case options
111
126
  }
112
127
 
128
+ enum SearchMode {
129
+ case any
130
+ case song
131
+ case playlist
132
+ case station
133
+ case podcast
134
+ case audiobook
135
+ case news
136
+ case music
137
+ case musicVideo
138
+ case movie
139
+ case tvShow
140
+ case tvShowEpisode
141
+
142
+ var stringValue: String {
143
+ switch self {
144
+ case .any: "any"
145
+ case .song: "song"
146
+ case .playlist: "playlist"
147
+ case .station: "station"
148
+ case .podcast: "podcast"
149
+ case .audiobook: "audiobook"
150
+ case .news: "news"
151
+ case .music: "music"
152
+ case .musicVideo: "music-video"
153
+ case .movie: "movie"
154
+ case .tvShow: "tv-show"
155
+ case .tvShowEpisode: "tv-show-episode"
156
+ }
157
+ }
158
+ }
159
+
160
+ enum MediaReference {
161
+ case my
162
+ case unknown
163
+
164
+ var stringValue: String {
165
+ switch self {
166
+ case .my: "my"
167
+ case .unknown: "unknown"
168
+ }
169
+ }
170
+ }
171
+
172
+ struct SearchParams {
173
+ var mode: SearchMode?
174
+ var query: String
175
+ var genre: String?
176
+ var artist: String?
177
+ var album: String?
178
+ var title: String?
179
+ var playlist: String?
180
+ var reference: MediaReference
181
+ }
182
+
183
+ struct ImageContext {
184
+ var width: Double?
185
+ var height: Double?
186
+ }
187
+
188
+ struct ImageQueryParams {
189
+ var width: String?
190
+ var height: String?
191
+ }
192
+
113
193
  struct ImageSource: Equatable {
114
194
  var uri: String
115
195
  var method: HttpMethod?
@@ -12,6 +12,8 @@ class PlayerUpdateOptions {
12
12
  var forwardJumpInterval: Double = 15.0
13
13
  var backwardJumpInterval: Double = 15.0
14
14
  var progressUpdateEventInterval: Double?
15
+ /// Set by `setPlaybackIntervalEnabled`, not by `update(from:)`; not reported in `toOptions()`.
16
+ var playbackIntervalEnabled = false
15
17
 
16
18
  /// Player capabilities - most enabled by default, only false values disable
17
19
  /// Exceptions: jumpForward, jumpBackward default to false
@@ -1,89 +1,17 @@
1
1
  import Foundation
2
2
 
3
- // Internal on purpose: nothing outside the module uses this, and a public
4
- // declaration gets printed into the generated -Swift.h C++ interop section,
5
- // where the [NSNumber] associated values don't compile for plain-C++
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 skipForward(preferredIntervals: [NSNumber])
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
- var enabled = true
21
+ private let enabled: Bool
22
22
  var formatter: ((_ params: FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?
23
23
 
24
- /// Whether the device is online, which tells a stall apart: offline or buffering.
25
- var isOnline = true {
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(nowPlayingInfoController: NowPlayingInfoController) {
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
@@ -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. The host debounces it.
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
- callbacks?.playerShouldReleaseSession()
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-establishes a stalled stream when connectivity returns. AVPlayer doesn't reliably report
843
- /// a live stream's mid-stream drop as a failure: it waits with no error, so the retry loop
844
- /// never engages. Only while stalled (.buffering) with play intent, so a pause or a retry that
845
- /// gave up isn't resumed; and not while the retry loop is itself waiting for the network,
846
- /// which owns that reload.
847
- func networkDidReturn() {
848
- guard playWhenReady, state == .buffering, !retry.isWaitingForNetwork else { return }
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
  }
@@ -15,9 +15,6 @@
15
15
  func playerDidChangeRepeatMode(_ event: RepeatModeChangedEvent)
16
16
  func playerDidChangeShuffleEnabled(_ enabled: Bool)
17
17
  func playerDidError(_ event: PlaybackErrorEvent)
18
- /// Playback is deliberately stopped — play intent is off, it isn't an interruption-driven pause
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(sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }) {
104
+ init(
105
+ networkMonitor: (any NetworkStatusProviding)? = nil,
106
+ sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) },
107
+ ) {
108
+ self.networkMonitor = networkMonitor
105
109
  self.sleep = sleep
106
110
  }
107
111
 
@@ -17,9 +17,6 @@
17
17
  /// Called when stop is triggered remotely.
18
18
  func remoteStop()
19
19
 
20
- /// Called when toggle play/pause is triggered remotely.
21
- func remotePlayPause()
22
-
23
20
  /// Called when next is triggered remotely.
24
21
  func remoteNext()
25
22
 
@@ -39,7 +39,10 @@ final class RemoteCommandController {
39
39
 
40
40
  weak var callbacks: RemoteCommandCallbacks?
41
41
 
42
- private var enabledCommands: [RemoteCommand] = []
42
+ private var enabledCommands: Set<RemoteCommand> = []
43
+ private var forwardJumpInterval: Double = 15
44
+ private var backwardJumpInterval: Double = 15
45
+ private var playbackRates: [Double] = []
43
46
 
44
47
  /// Whether the queue has a next / previous track; read whenever those commands are enabled.
45
48
  var skipAvailability: @MainActor () -> (canNext: Bool, canPrevious: Bool) = { (true, true) }
@@ -62,18 +65,27 @@ final class RemoteCommandController {
62
65
  func switchCommandCenter(_ newCenter: MPRemoteCommandCenter) {
63
66
  guard newCenter !== center else { return }
64
67
  let commands = enabledCommands
65
- disableAll()
68
+ commands.forEach(disable)
66
69
  center = newCenter
67
- enable(commands: commands)
70
+ commands.forEach(enable)
68
71
  center.changeShuffleModeCommand.currentShuffleType = shuffleEnabled.mpShuffleType
69
72
  center.changeRepeatModeCommand.currentRepeatType = repeatMode.mpRepeatType
70
73
  logger.info("Switched command center, re-enabled \(commands.count) commands")
71
74
  }
72
75
 
73
- /// Enables exactly `commands`, disabling any enabled command they no longer include.
74
- func enable(commands: [RemoteCommand]) {
75
- RemoteCommand.commandsToDisable(enabled: enabledCommands, replacedBy: commands).forEach(disable)
76
+ /// Enables exactly `commands`, disabling any enabled command they no longer include. The skip
77
+ /// commands offer their interval, the rate command `playbackRates`.
78
+ func enable(
79
+ commands: Set<RemoteCommand>,
80
+ forwardJumpInterval: Double = 15,
81
+ backwardJumpInterval: Double = 15,
82
+ playbackRates: [Double] = [],
83
+ ) {
84
+ enabledCommands.subtracting(commands).forEach(disable)
76
85
  enabledCommands = commands
86
+ self.forwardJumpInterval = forwardJumpInterval
87
+ self.backwardJumpInterval = backwardJumpInterval
88
+ self.playbackRates = playbackRates
77
89
  commands.forEach(enable)
78
90
  }
79
91
 
@@ -106,7 +118,6 @@ final class RemoteCommandController {
106
118
  case .play: center.playCommand
107
119
  case .pause: center.pauseCommand
108
120
  case .stop: center.stopCommand
109
- case .togglePlayPause: center.togglePlayPauseCommand
110
121
  case .next: center.nextTrackCommand
111
122
  case .previous: center.previousTrackCommand
112
123
  case .changePlaybackPosition: center.changePlaybackPositionCommand
@@ -120,9 +131,10 @@ final class RemoteCommandController {
120
131
 
121
132
  private func enable(_ command: RemoteCommand) {
122
133
  switch command {
123
- case let .skipForward(intervals): center.skipForwardCommand.preferredIntervals = intervals
124
- case let .skipBackward(intervals): center.skipBackwardCommand.preferredIntervals = intervals
125
- case let .changePlaybackRate(rates): center.changePlaybackRateCommand.supportedPlaybackRates = rates
134
+ case .skipForward: center.skipForwardCommand.preferredIntervals = [NSNumber(value: forwardJumpInterval)]
135
+ case .skipBackward: center.skipBackwardCommand.preferredIntervals = [NSNumber(value: backwardJumpInterval)]
136
+ case .changePlaybackRate:
137
+ center.changePlaybackRateCommand.supportedPlaybackRates = playbackRates.map { NSNumber(value: $0) }
126
138
  default: break
127
139
  }
128
140
  let mpCommand = mpCommand(for: command)
@@ -148,14 +160,13 @@ final class RemoteCommandController {
148
160
  case .play: callbacks?.remotePlay()
149
161
  case .pause: callbacks?.remotePause()
150
162
  case .stop: callbacks?.remoteStop()
151
- case .togglePlayPause: callbacks?.remotePlayPause()
152
163
  case .next: callbacks?.remoteNext()
153
164
  case .previous: callbacks?.remotePrevious()
154
165
  case .skipForward, .skipBackward:
155
166
  guard let interval = (event.command as? MPSkipIntervalCommand)?.preferredIntervals.first else {
156
167
  return .commandFailed
157
168
  }
158
- if case .skipForward = command {
169
+ if command == .skipForward {
159
170
  callbacks?.remoteJumpForward(interval: interval.doubleValue)
160
171
  } else {
161
172
  callbacks?.remoteJumpBackward(interval: interval.doubleValue)
@@ -177,3 +177,36 @@ import Testing
177
177
  #expect(BrowserPathHelper.isSearchPath("/search?q=jazz") == false)
178
178
  #expect(BrowserPathHelper.isSearchPath("/__searchable") == false)
179
179
  }
180
+
181
+ // MARK: - stampingContextualPaths
182
+
183
+ @Test func stampsPlayableOnlyTracksWithTheirFlatPagePosition() throws {
184
+ let sections = [
185
+ Section(children: [Track(path: "/a", title: "A"), Track(src: "one.mp3", title: "One")]),
186
+ Section(children: [Track(id: "two", src: "two.mp3", title: "Two")]),
187
+ ]
188
+ let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
189
+ #expect(stamped.map { $0.children.map(\.path) } == [
190
+ ["/a", "/page?__trackId=one.mp3&__index=1"],
191
+ ["/page?__trackId=two&__index=2"],
192
+ ])
193
+ }
194
+
195
+ @Test func keepsTheSectionsAndATrackThatHasAPath() throws {
196
+ let track = Track(path: "/a", src: "a.mp3", title: "A")
197
+ let sections = [Section(title: "Top", path: "/top", children: [track])]
198
+ #expect(try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page") == sections)
199
+ }
200
+
201
+ @Test func stampsBesideAnExistingQuery() throws {
202
+ let sections = [Section(children: [Track(src: "s.mp3", title: "S")])]
203
+ let stamped = try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/search?q=jazz")
204
+ #expect(stamped[0].children[0].path == "/search?q=jazz&__trackId=s.mp3&__index=0")
205
+ }
206
+
207
+ @Test func rejectsATrackWithNeitherPathNorSrc() {
208
+ let sections = [Section(children: [Track(id: "only-id", title: "Nowhere")])]
209
+ #expect(throws: BrowserError.self) {
210
+ try BrowserPathHelper.stampingContextualPaths(sections, parentPath: "/page")
211
+ }
212
+ }
@@ -0,0 +1,35 @@
1
+ import Testing
2
+
3
+ @testable import AudioBrowserTestable
4
+
5
+ @Suite("PlayerCapabilities.remoteCommands")
6
+ struct CapabilityRemoteCommandTests {
7
+ @Test func everyCommandIsEnabledUnlessSetToFalse() {
8
+ #expect(PlayerCapabilities().remoteCommands == Set(RemoteCommand.allCases))
9
+ }
10
+
11
+ @Test func eachCapabilitySetToFalseDropsExactlyItsCommand() {
12
+ let pairs: [(WritableKeyPath<PlayerCapabilities, Bool?>, RemoteCommand)] = [
13
+ (\.play, .play),
14
+ (\.pause, .pause),
15
+ (\.stop, .stop),
16
+ (\.skipToNext, .next),
17
+ (\.skipToPrevious, .previous),
18
+ (\.seekTo, .changePlaybackPosition),
19
+ (\.jumpForward, .skipForward),
20
+ (\.jumpBackward, .skipBackward),
21
+ (\.shuffleMode, .changeShuffleMode),
22
+ (\.repeatMode, .changeRepeatMode),
23
+ (\.playbackRate, .changePlaybackRate),
24
+ ]
25
+ for (capability, command) in pairs {
26
+ var capabilities = PlayerCapabilities()
27
+ capabilities[keyPath: capability] = false
28
+ #expect(capabilities.remoteCommands == Set(RemoteCommand.allCases).subtracting([command]), "\(command)")
29
+ }
30
+ }
31
+
32
+ @Test func favoriteEnablesNoCommand() {
33
+ #expect(PlayerCapabilities(favorite: false).remoteCommands == Set(RemoteCommand.allCases))
34
+ }
35
+ }
@@ -69,55 +69,12 @@ struct RemoveListenerTests {
69
69
 
70
70
  @Test func doubleRemoveSameTokenIsSafe() {
71
71
  let emitter = Emitter<Int>()
72
- let token = emitter.addListener { _ in }
72
+ var received = [Int]()
73
+ let token = emitter.addListener { received.append($0) }
73
74
  emitter.removeListener(token)
74
75
  emitter.removeListener(token)
75
- #expect(emitter.listenerCount == 0)
76
- }
77
- }
78
-
79
- // MARK: - removeAllListeners
80
-
81
- @Suite("removeAllListeners")
82
- struct RemoveAllListenersTests {
83
- @Test func clearsAllAndCountGoesToZero() {
84
- let emitter = Emitter<Int>()
85
- emitter.addListener { _ in }
86
- emitter.addListener { _ in }
87
- emitter.addListener { _ in }
88
- emitter.removeAllListeners()
89
- #expect(emitter.listenerCount == 0)
90
- }
91
-
92
- @Test func previouslyAddedListenersStopReceiving() {
93
- let emitter = Emitter<Int>()
94
- var called = false
95
- emitter.addListener { _ in called = true }
96
- emitter.removeAllListeners()
97
76
  emitter.emit(1)
98
- #expect(called == false)
99
- }
100
- }
101
-
102
- // MARK: - listenerCount
103
-
104
- @Suite("listenerCount")
105
- struct ListenerCountTests {
106
- @Test func reflectsAddsAndRemoves() {
107
- let emitter = Emitter<Int>()
108
- #expect(emitter.listenerCount == 0)
109
-
110
- let t1 = emitter.addListener { _ in }
111
- #expect(emitter.listenerCount == 1)
112
-
113
- let t2 = emitter.addListener { _ in }
114
- #expect(emitter.listenerCount == 2)
115
-
116
- emitter.removeListener(t1)
117
- #expect(emitter.listenerCount == 1)
118
-
119
- emitter.removeListener(t2)
120
- #expect(emitter.listenerCount == 0)
77
+ #expect(received.isEmpty)
121
78
  }
122
79
  }
123
80
 
@@ -133,7 +90,6 @@ struct SubscribeTests {
133
90
  subscription.cancel()
134
91
  emitter.emit(2)
135
92
  #expect(received == [1])
136
- #expect(emitter.listenerCount == 0)
137
93
  }
138
94
 
139
95
  @Test func releasingTheSubscriptionRemovesTheListener() {
@@ -146,6 +102,5 @@ struct SubscribeTests {
146
102
  }
147
103
  emitter.emit(2)
148
104
  #expect(received == [1])
149
- #expect(emitter.listenerCount == 0)
150
105
  }
151
106
  }