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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/ios/AVPlayerEngine.swift +60 -24
  2. package/ios/AVPlayerEngineCallbacks.swift +0 -1
  3. package/ios/CarPlay/CPInterfaceController+Safe.swift +0 -7
  4. package/ios/CarPlay/CarPlayController.swift +0 -6
  5. package/ios/CarPlay/CarPlayImageLoader.swift +44 -97
  6. package/ios/CarPlay/CarPlayListItemFactory.swift +74 -99
  7. package/ios/CarPlay/CarPlayNowPlayingManager.swift +13 -46
  8. package/ios/CarPlay/CarPlaySession+Navigator.swift +2 -13
  9. package/ios/CarPlay/CarPlaySession+PageLoader.swift +12 -13
  10. package/ios/CarPlay/CarPlaySession+Refresher.swift +13 -13
  11. package/ios/CarPlay/CarPlaySession+TabBar.swift +22 -23
  12. package/ios/CarPlay/CarPlaySession.swift +10 -12
  13. package/ios/CarPlay/IntentHandlers.swift +96 -0
  14. package/ios/CarPlay/MediaIntentCriteria.swift +38 -80
  15. package/ios/CarPlay/{RNABMediaIntentHandler.swift → MediaIntentHandler.swift} +3 -6
  16. package/ios/CarPlay/MediaIntentPlayback.swift +1 -1
  17. package/ios/CarPlay/NowPlayingButtonRules.swift +35 -0
  18. package/ios/CarPlay/RNABAudioBrowser.m +7 -15
  19. package/ios/Extension/Capability+RemoteCommand.swift +22 -69
  20. package/ios/Extension/Track+AVPlayer.swift +0 -16
  21. package/ios/HybridAudioBrowser+Browser.swift +16 -17
  22. package/ios/HybridAudioBrowser+Options.swift +27 -66
  23. package/ios/HybridAudioBrowser+Player.swift +4 -60
  24. package/ios/HybridAudioBrowser+PlayerCallbacks.swift +6 -38
  25. package/ios/HybridAudioBrowser.swift +36 -99
  26. package/ios/Model/NitroTypeStubs.swift +15 -0
  27. package/ios/Model/PlayerUpdateOptions.swift +2 -0
  28. package/ios/Model/RemoteCommand.swift +6 -78
  29. package/ios/Player/NowPlayingUpdater.swift +13 -7
  30. package/ios/Player/Player.swift +45 -10
  31. package/ios/Player/PlayerCallbacks.swift +1 -4
  32. package/ios/Player/PlayerEngine.swift +17 -0
  33. package/ios/Player/RetryLoop.swift +5 -1
  34. package/ios/RemoteCommand/RemoteCommandCallbacks.swift +0 -3
  35. package/ios/RemoteCommand/RemoteCommandController.swift +23 -12
  36. package/ios/Tests/CapabilityRemoteCommandTests.swift +35 -0
  37. package/ios/Tests/ConcurrentMapTests.swift +42 -0
  38. package/ios/Tests/EmitterTests.swift +3 -48
  39. package/ios/Tests/MediaIntentCriteriaTests.swift +57 -46
  40. package/ios/Tests/NowPlayingButtonRulesTests.swift +43 -0
  41. package/ios/Tests/PlayerPolicyTests.swift +27 -3
  42. package/ios/Tests/PlayerTests.swift +136 -17
  43. package/ios/Tests/RemoteCommandControllerTests.swift +44 -15
  44. package/ios/Tests/Support/TestTypes.swift +44 -3
  45. package/ios/TrackSelector.swift +34 -52
  46. package/ios/Util/ConcurrentMap.swift +19 -0
  47. package/ios/Util/Emitter.swift +0 -14
  48. package/ios/Util/NetworkMonitor.swift +8 -25
  49. package/ios/Util/OutputPicker.swift +48 -0
  50. package/package.json +1 -1
  51. package/ios/CarPlay/HybridAudioBrowser+IntentFavorite.swift +0 -20
  52. package/ios/CarPlay/RNABMediaAddHandler.swift +0 -59
  53. package/ios/CarPlay/RNABMediaAffinityHandler.swift +0 -53
  54. package/ios/Model/SourceType.swift +0 -6
  55. package/ios/Tests/RemoteCommandTests.swift +0 -65
@@ -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 releaseSessionCount = 0
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 playerShouldReleaseSession() {
204
- releaseSessionCount += 1
243
+ func playerDidReceiveTimedMetadata(_ metadata: TimedMetadata) {
244
+ publishOrder.append("timedMetadata")
245
+ timedMetadata.append(metadata)
205
246
  }
206
247
 
207
248
  var sleepTimerChanges: [SleepTimerState?] = []
@@ -65,34 +65,42 @@ class TrackSelector {
65
65
  return .none
66
66
  }
67
67
 
68
- let path = track.path
69
-
70
- // 1. Contextual path (playable-only track with queue context)
71
- if let path, BrowserPathHelper.isContextual(path) {
72
- return await handleContextualPath(path, track: track, player: player)
73
- }
74
-
75
- // 2. Has src — single playable track
76
- if track.src != nil {
77
- return await handlePlayableTrack(track, player: player)
78
- }
79
-
80
- // 3. Has path — browsable
81
- if let path {
68
+ let playback: PlannedPlayback
69
+ if let path = track.path, BrowserPathHelper.isContextual(path) {
70
+ playback = await contextualPlayback(path, track: track, player: player)
71
+ } else if track.src != nil {
72
+ playback = .single(track)
73
+ } else if let path = track.path {
82
74
  return .browse(path: path)
75
+ } else {
76
+ return .none
83
77
  }
84
78
 
85
- // 4. Neither
86
- return .none
79
+ let event = TrackLoadEvent(track: track, queue: playback.queue, startIndex: Double(playback.index))
80
+ if await browserManager.awaitTrackLoadHandler(event: event) {
81
+ return .intercepted
82
+ }
83
+ return .play(playback.intent)
87
84
  }
88
85
 
89
86
  // MARK: - Private Helpers
90
87
 
91
- private func handleContextualPath(
88
+ /// What a selection plays, and the queue and index the track-load handler is shown.
89
+ private struct PlannedPlayback {
90
+ let queue: [Track]
91
+ let index: Int
92
+ let intent: PlaybackIntent
93
+
94
+ static func single(_ track: Track) -> PlannedPlayback {
95
+ PlannedPlayback(queue: [track], index: 0, intent: .loadTrack(track))
96
+ }
97
+ }
98
+
99
+ private func contextualPlayback(
92
100
  _ path: String,
93
101
  track: Track,
94
102
  player: some TrackSelectionPlayer,
95
- ) async -> SelectionResult {
103
+ ) async -> PlannedPlayback {
96
104
  let parentPath = BrowserPathHelper.stripTrackId(path)
97
105
  let trackId = BrowserPathHelper.extractTrackId(path)
98
106
 
@@ -110,47 +118,21 @@ class TrackSelector {
110
118
  }
111
119
  if let index = skipIndex {
112
120
  logger.debug("Queue already from \(parentPath), skipping to index \(index)")
113
- let queue = player.tracks
114
- let event = TrackLoadEvent(track: track, queue: queue, startIndex: Double(index))
115
- if await browserManager.awaitTrackLoadHandler(event: event) {
116
- return .intercepted
117
- }
118
- return .play(.skipTo(index: index))
121
+ return PlannedPlayback(queue: player.tracks, index: index, intent: .skipTo(index: index))
119
122
  }
120
123
  }
121
124
 
122
- // Expand the queue from the contextual path
123
125
  do {
124
- if let expanded = try await browserManager.expandQueueFromContextualPath(path) {
125
- let (tracks, startIndex) = expanded
126
- let event = TrackLoadEvent(track: track, queue: tracks, startIndex: Double(startIndex))
127
- if await browserManager.awaitTrackLoadHandler(event: event) {
128
- return .intercepted
129
- }
130
- return .play(.setQueue(tracks: tracks, startIndex: startIndex, sourcePath: parentPath))
131
- } else {
132
- // Fallback: single track
133
- return await singleTrackResult(track)
126
+ guard let (tracks, startIndex) = try await browserManager.expandQueueFromContextualPath(path) else {
127
+ return .single(track)
134
128
  }
129
+ return PlannedPlayback(
130
+ queue: tracks, index: startIndex,
131
+ intent: .setQueue(tracks: tracks, startIndex: startIndex, sourcePath: parentPath),
132
+ )
135
133
  } catch {
136
134
  logger.error("Error expanding queue: \(error.localizedDescription)")
137
- // Fallback to single track
138
- return await singleTrackResult(track)
139
- }
140
- }
141
-
142
- private func handlePlayableTrack(
143
- _ track: Track,
144
- player _: some TrackSelectionPlayer,
145
- ) async -> SelectionResult {
146
- await singleTrackResult(track)
147
- }
148
-
149
- private func singleTrackResult(_ track: Track) async -> SelectionResult {
150
- let event = TrackLoadEvent(track: track, queue: [track], startIndex: 0)
151
- if await browserManager.awaitTrackLoadHandler(event: event) {
152
- return .intercepted
135
+ return .single(track)
153
136
  }
154
- return .play(.loadTrack(track))
155
137
  }
156
138
  }
@@ -0,0 +1,19 @@
1
+ extension Array where Element: Sendable {
2
+ /// `transform` over every element at once, with the results in element order. Cancelling the
3
+ /// caller cancels every transform still running.
4
+ func concurrentMap<T: Sendable>(_ transform: @escaping @MainActor (Element) async -> T) async -> [T] {
5
+ await withTaskGroup(of: (Int, T).self) { group in
6
+ for (index, element) in enumerated() {
7
+ group.addTask {
8
+ let result = await transform(element)
9
+ return (index, result)
10
+ }
11
+ }
12
+ var results: [(Int, T)] = []
13
+ for await result in group {
14
+ results.append(result)
15
+ }
16
+ return results.sorted { $0.0 < $1.0 }.map(\.1)
17
+ }
18
+ }
19
+ }
@@ -40,13 +40,6 @@ public final class Emitter<T>: @unchecked Sendable {
40
40
  snapshot = next
41
41
  }
42
42
 
43
- /// Removes all listeners
44
- public func removeAllListeners() {
45
- lock.lock()
46
- defer { lock.unlock() }
47
- snapshot = []
48
- }
49
-
50
43
  /// Emits an event to all registered listeners
51
44
  /// - Parameter event: The event data to emit
52
45
  public func emit(_ event: T) {
@@ -58,13 +51,6 @@ public final class Emitter<T>: @unchecked Sendable {
58
51
  entry.handler(event)
59
52
  }
60
53
  }
61
-
62
- /// Returns the number of registered listeners
63
- public var listenerCount: Int {
64
- lock.lock()
65
- defer { lock.unlock() }
66
- return snapshot.count
67
- }
68
54
  }
69
55
 
70
56
  /// A listener registration that ends when cancelled or released.
@@ -2,8 +2,7 @@ import Foundation
2
2
  import Network
3
3
  import os.log
4
4
 
5
- /// Monitors network connectivity state using NWPathMonitor.
6
- /// Notifies listeners when the connection state changes.
5
+ /// The device's connectivity, from NWPathMonitor; `changed` emits each change on the main thread.
7
6
  final class NetworkMonitor: @unchecked Sendable {
8
7
  // MARK: - Properties
9
8
 
@@ -11,25 +10,17 @@ final class NetworkMonitor: @unchecked Sendable {
11
10
  private let monitor: NWPathMonitor
12
11
  private let queue = DispatchQueue(label: "com.audiobrowser.networkmonitor")
13
12
 
14
- /// Guards `_isOnline` only. Never held across `onChanged` — see `apply`.
13
+ /// Guards `_isOnline` only; never held across an emit.
15
14
  private let lock = NSLock()
16
15
  private var _isOnline = false
17
16
 
18
- /// Current network connectivity state. Readable from any thread: the value is
19
- /// lock-protected rather than confined, so callers on the JS thread (Nitro's
20
- /// `getOnline`) don't have to block on a main-queue hop to read a Bool.
21
- ///
22
- /// The broadcast is main-confined even though the value isn't — `onChanged`
23
- /// fans out to the JS bridge, a now-playing re-render and a player reload, so
24
- /// it must not run under the lock (reentrancy) or off the main actor.
17
+ /// Readable from any thread, so the JS thread's `getOnline` needs no main-queue hop.
25
18
  var isOnline: Bool {
26
19
  lock.lock()
27
20
  defer { lock.unlock() }
28
21
  return _isOnline
29
22
  }
30
23
 
31
- /// Stores a new status and, if it actually changed, broadcasts on main —
32
- /// outside the lock.
33
24
  private func apply(_ newStatus: Bool) {
34
25
  lock.lock()
35
26
  let changed = _isOnline != newStatus
@@ -40,28 +31,20 @@ final class NetworkMonitor: @unchecked Sendable {
40
31
  guard changed else { return }
41
32
  logger.notice("Network status changed: \(oldValue) -> \(newStatus)")
42
33
  DispatchQueue.main.async { [weak self] in
43
- self?.onChanged?(newStatus)
34
+ self?.changed.emit(newStatus)
44
35
  }
45
36
  }
46
37
 
47
- /// Invoked on every connectivity change. Wired centrally in `HybridAudioBrowser.setupPlayer` and
48
- /// fans out to the JS event bridge, the now-playing re-render (so the offline/online label tracks
49
- /// connectivity), and the player's reconnect-reload.
50
- var onChanged: ((Bool) -> Void)?
38
+ let changed = Emitter<Bool>()
51
39
 
52
40
  // MARK: - Initialization
53
41
 
54
42
  init() {
55
43
  monitor = NWPathMonitor()
56
44
 
57
- // Seed BEFORE `start`. `start` publishes `self` to the monitor queue, so any
58
- // write after it races the handler's locked write — and would clobber a real
59
- // update with a `currentPath` the monitor hasn't finished evaluating. Nothing
60
- // recovers from that: the handler has already fired, `onChanged` isn't wired
61
- // until setupPlayer, and the stale value then drives `getOnline()`, the JS
62
- // event, and RetryLoop's offline parking until the next genuine path
63
- // change. Seeding first is a best-effort guess; the handler fires shortly
64
- // after `start` and is the authoritative last writer.
45
+ // Seeded before `start`: a write after it races the handler's, and would clobber a real
46
+ // update with a `currentPath` the monitor hasn't finished evaluating. The handler, which
47
+ // fires shortly after `start`, is the last writer.
65
48
  let initialStatus = monitor.currentPath.status == .satisfied
66
49
  logger.notice("NetworkMonitor initialized, initial isOnline=\(initialStatus)")
67
50
  _isOnline = initialStatus
@@ -0,0 +1,48 @@
1
+ import AVKit
2
+ import os.log
3
+ import UIKit
4
+
5
+ /// Presents the system audio route picker over the topmost view controller.
6
+ @MainActor
7
+ enum OutputPicker {
8
+ private static let logger = Logger(subsystem: "com.audiobrowser", category: "OutputPicker")
9
+
10
+ static func present() {
11
+ guard let windowScene = UIApplication.shared.activeWindowScene,
12
+ let window = windowScene.windows.first(where: { $0.isKeyWindow }),
13
+ var topController = window.rootViewController
14
+ else {
15
+ logger.error("openOutputPicker: no active window scene / root view controller")
16
+ return
17
+ }
18
+ while let presented = topController.presentedViewController {
19
+ topController = presented
20
+ }
21
+
22
+ // AVRoutePickerView has no API to present its picker, so its internal button is tapped. The
23
+ // view must be visible to the system (a hidden source view suppresses the picker), and the
24
+ // button can sit below the top-level subviews.
25
+ let routePicker = AVRoutePickerView(frame: CGRect(x: 0, y: 0, width: 1, height: 1))
26
+ routePicker.alpha = 0.01
27
+ topController.view.addSubview(routePicker)
28
+
29
+ if let button = firstButton(in: routePicker) {
30
+ button.sendActions(for: .touchUpInside)
31
+ } else {
32
+ logger.error("openOutputPicker: no UIButton found inside AVRoutePickerView")
33
+ }
34
+
35
+ // On iPad the picker's popover anchors to this view.
36
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
37
+ routePicker.removeFromSuperview()
38
+ }
39
+ }
40
+
41
+ private static func firstButton(in view: UIView) -> UIButton? {
42
+ if let button = view as? UIButton { return button }
43
+ for subview in view.subviews {
44
+ if let button = firstButton(in: subview) { return button }
45
+ }
46
+ return nil
47
+ }
48
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-browser",
3
- "version": "0.2.0-next.33",
3
+ "version": "0.2.0-next.35",
4
4
  "description": "react-native-audio-browser is a react native package built with Nitro",
5
5
  "keywords": [
6
6
  "react-native",
@@ -1,20 +0,0 @@
1
- import Foundation
2
- import os.log
3
-
4
- private let intentLogger = Logger(subsystem: "com.audiobrowser", category: "MediaIntent")
5
-
6
- extension HybridAudioBrowser {
7
- /// Sets the favorite state of the playing track for a Siri intent, through the same path as
8
- /// the CarPlay favorite button. False when nothing is playing, the playing track has no
9
- /// identity to favorite, or the change throws.
10
- @MainActor
11
- static func favoriteCurrentTrack(_ favorited: Bool) -> Bool {
12
- guard let browser = shared else { return false }
13
- do {
14
- return try browser.favoriteActiveTrack(favorited)
15
- } catch {
16
- intentLogger.error("favoriteCurrentTrack(\(favorited)) failed: \(error.localizedDescription)")
17
- return false
18
- }
19
- }
20
- }
@@ -1,59 +0,0 @@
1
- import Foundation
2
- import Intents
3
- import os.log
4
-
5
- /// In-app `INAddMediaIntent` handler — "Hey Siri, add this to my favorites."
6
- /// Adding to the user's collection maps onto the library-managed favorite state
7
- /// of the **currently playing** track via `setActiveTrackFavorited` — the same
8
- /// path as the CarPlay favorite button and the `INUpdateMediaAffinityIntent`
9
- /// "like", so it flips the now-playing heart and fires `onFavoriteChanged` for
10
- /// the consumer to persist. No extra config beyond the `favorite` capability.
11
- ///
12
- /// We expose a single flat favorites collection (no named playlists), so the
13
- /// destination always resolves to the user's library. Adding a *named* item
14
- /// that isn't currently playing needs the structured-intent consumer callback
15
- /// and is out of scope here — like the named play-by-search case.
16
- ///
17
- /// Internal (keeps Intents types out of the generated header), exposed to the
18
- /// ObjC runtime via `@objc(RNABMediaAddHandler)` and vended from
19
- /// `RNABAudioBrowser.handlerForIntent(_:)`.
20
- @objc(RNABMediaAddHandler)
21
- class RNABMediaAddHandler: NSObject, INAddMediaIntentHandling {
22
- private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaAddHandler")
23
-
24
- func handle(
25
- intent _: INAddMediaIntent,
26
- completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void,
27
- ) {
28
- Self.logger.info("Add media — favorite current track")
29
-
30
- Task { @MainActor in
31
- let applied = HybridAudioBrowser.favoriteCurrentTrack(true)
32
- completion(INAddMediaIntentResponse(code: applied ? .success : .failure, userActivity: nil))
33
- }
34
- }
35
-
36
- // Siri runs a resolve phase before `handle`; without a resolution method it
37
- // errors ("Unable to find implementation of resolution method"). These are
38
- // *optional* @objc protocol methods, so pin the exact selectors SiriKit
39
- // dispatches to (otherwise they aren't exposed and are "not found"). We act on
40
- // the currently-playing track / our single library, so pass both through.
41
- @objc(resolveMediaItemsForAddMedia:withCompletion:)
42
- func resolveMediaItems(
43
- for intent: INAddMediaIntent,
44
- with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void,
45
- ) {
46
- let name = intent.mediaSearch?.mediaName ?? ""
47
- let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
48
- completion([.success(with: item)])
49
- }
50
-
51
- @objc(resolveMediaDestinationForAddMedia:withCompletion:)
52
- func resolveMediaDestination(
53
- for _: INAddMediaIntent,
54
- with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void,
55
- ) {
56
- // One flat favorites collection — always the user's library, no playlists.
57
- completion(.success(with: INMediaDestination.library))
58
- }
59
- }
@@ -1,53 +0,0 @@
1
- import Foundation
2
- import Intents
3
- import os.log
4
-
5
- /// In-app `INUpdateMediaAffinityIntent` handler — "Hey Siri, I like / don't like
6
- /// this." Maps the affinity onto the library-managed favorite state of the
7
- /// **currently playing** track via `setActiveTrackFavorited` — the same path as
8
- /// the CarPlay favorite button — so it flips the now-playing heart and fires
9
- /// `onFavoriteChanged` for the consumer to persist. Like → favorited, dislike →
10
- /// unfavorited. No extra config beyond the `favorite` capability.
11
- ///
12
- /// Internal (keeps Intents types out of the generated header), exposed to the
13
- /// ObjC runtime via `@objc(RNABMediaAffinityHandler)` and vended from
14
- /// `RNABAudioBrowser.handlerForIntent(_:)`.
15
- ///
16
- /// Affinity for a *named* item ("I like «station»") is out of scope here — that
17
- /// needs the structured-intent consumer callback.
18
- @objc(RNABMediaAffinityHandler)
19
- class RNABMediaAffinityHandler: NSObject, INUpdateMediaAffinityIntentHandling {
20
- private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaAffinityHandler")
21
-
22
- func handle(
23
- intent: INUpdateMediaAffinityIntent,
24
- completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void,
25
- ) {
26
- let affinity = intent.affinityType
27
- Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
28
-
29
- Task { @MainActor in
30
- let applied = switch affinity {
31
- case .like: HybridAudioBrowser.favoriteCurrentTrack(true)
32
- case .dislike: HybridAudioBrowser.favoriteCurrentTrack(false)
33
- default: false
34
- }
35
- completion(INUpdateMediaAffinityIntentResponse(code: applied ? .success : .failure, userActivity: nil))
36
- }
37
- }
38
-
39
- // Siri runs a resolve phase before `handle`; without a resolution method it
40
- // errors ("Unable to find implementation of resolution method"). This is an
41
- // *optional* @objc protocol method, so pin the exact selector SiriKit dispatches
42
- // to (otherwise it isn't exposed and is "not found"). We act on the currently
43
- // playing track regardless, so pass the spoken item straight through.
44
- @objc(resolveMediaItemsForUpdateMediaAffinity:withCompletion:)
45
- func resolveMediaItems(
46
- for intent: INUpdateMediaAffinityIntent,
47
- with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void,
48
- ) {
49
- let name = intent.mediaSearch?.mediaName ?? ""
50
- let item = INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
51
- completion([.success(with: item)])
52
- }
53
- }
@@ -1,6 +0,0 @@
1
- import Foundation
2
-
3
- public enum SourceType {
4
- case stream
5
- case file
6
- }
@@ -1,65 +0,0 @@
1
- import Foundation
2
- import Testing
3
-
4
- @testable import AudioBrowserTestable
5
-
6
- @Suite("RemoteCommand")
7
- struct RemoteCommandTests {
8
- @Test func removedCommandsAreDisabled() {
9
- let toDisable = RemoteCommand.commandsToDisable(
10
- enabled: [.play, .pause, .next, .previous],
11
- replacedBy: [.play, .pause],
12
- )
13
- #expect(toDisable.map(\.key) == ["nextTrack", "previousTrack"])
14
- }
15
-
16
- @Test func retainedCommandsAreNotDisabled() {
17
- let toDisable = RemoteCommand.commandsToDisable(
18
- enabled: [.play, .pause],
19
- replacedBy: [.play, .pause, .stop],
20
- )
21
- #expect(toDisable.isEmpty)
22
- }
23
-
24
- /// A changed jump interval is the same command with a new associated value —
25
- /// diffing by `==` would report it as removed and disable the button that the
26
- /// update had just re-enabled.
27
- @Test func changedJumpIntervalIsNotDisabled() {
28
- let toDisable = RemoteCommand.commandsToDisable(
29
- enabled: [.skipForward(preferredIntervals: [15]), .skipBackward(preferredIntervals: [15])],
30
- replacedBy: [.skipForward(preferredIntervals: [30]), .skipBackward(preferredIntervals: [30])],
31
- )
32
- #expect(toDisable.isEmpty)
33
- }
34
-
35
- @Test func changedPlaybackRatesAreNotDisabled() {
36
- let toDisable = RemoteCommand.commandsToDisable(
37
- enabled: [.changePlaybackRate(supportedPlaybackRates: [1.0])],
38
- replacedBy: [.changePlaybackRate(supportedPlaybackRates: [1.0, 1.5, 2.0])],
39
- )
40
- #expect(toDisable.isEmpty)
41
- }
42
-
43
- @Test func everyCommandIsDisabledWhenNoneRemain() {
44
- let toDisable = RemoteCommand.commandsToDisable(
45
- enabled: RemoteCommand.all(),
46
- replacedBy: [],
47
- )
48
- #expect(toDisable.count == RemoteCommand.all().count)
49
- }
50
-
51
- /// `key` is the identity the diff above compares; two cases sharing a key would
52
- /// disable each other.
53
- @Test func keysAreUnique() {
54
- let all = RemoteCommand.all()
55
- #expect(Set(all.map(\.key)).count == all.count)
56
- }
57
-
58
- @Test func droppedJumpCommandIsStillDisabled() {
59
- let toDisable = RemoteCommand.commandsToDisable(
60
- enabled: [.play, .skipForward(preferredIntervals: [15])],
61
- replacedBy: [.play],
62
- )
63
- #expect(toDisable.map(\.key) == ["skipForward"])
64
- }
65
- }