react-native-audio-browser 0.2.0-next.34 → 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.
@@ -26,19 +26,18 @@ extension CarPlaySession {
26
26
  NavigationError(code: .timeout, message: "", statusCode: nil, statusCodeSuccess: nil), path: path, on: template,
27
27
  )
28
28
  }
29
- // Cancelled as soon as the resolve settles: formatting its error is a JS round trip the
30
- // timeout must not overwrite.
31
- defer { watchdog.cancel() }
32
-
29
+ let outcome: Result<ResolvedTrack, Error>
33
30
  do {
34
- let resolved: ResolvedTrack
35
- do {
36
- resolved = try await audioBrowser.browserManager.resolve(path, useCache: true)
37
- } catch {
38
- watchdog.cancel()
39
- throw error
40
- }
41
- watchdog.cancel()
31
+ outcome = try await .success(audioBrowser.browserManager.resolve(path, useCache: true))
32
+ } catch {
33
+ outcome = .failure(error)
34
+ }
35
+ // As soon as the resolve settles: formatting its error is a JS round trip the timeout must
36
+ // not overwrite.
37
+ watchdog.cancel()
38
+
39
+ switch outcome {
40
+ case let .success(resolved):
42
41
  guard !Task.isCancelled else { return }
43
42
  // An assistant cell is content in itself, even with no rows; a page of empty sections is
44
43
  // empty (ADR 0001).
@@ -48,7 +47,7 @@ extension CarPlaySession {
48
47
  } else {
49
48
  await showNavigationError(.code(.emptyContent), path: path, on: template)
50
49
  }
51
- } catch {
50
+ case let .failure(error):
52
51
  logger.error("Failed to load content for \(path): \(error.localizedDescription)")
53
52
  await showNavigationError(NavigationError.from(error), path: path, on: template)
54
53
  }
@@ -23,21 +23,21 @@ extension CarPlaySession {
23
23
  /// invalidated (e.g. a locale change).
24
24
  func refreshAllDisplayedTemplates() async {
25
25
  let browserManager = audioBrowser.browserManager
26
- let loads = displayedListTemplates().compactMap { template in
27
- template.contentPath.map { path in
28
- (template, Task<ResolvedTrack?, Never> { @MainActor [logger] in
29
- do {
30
- return try await browserManager.resolve(path, useCache: false)
31
- } catch {
32
- logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
33
- return nil
34
- }
35
- })
26
+ let targets = displayedListTemplates().compactMap { template in template.contentPath.map { (template, $0) } }
27
+ let pages = await targets.map(\.1).concurrentMap { [logger] path -> ResolvedTrack? in
28
+ do {
29
+ return try await browserManager.resolve(path, useCache: false)
30
+ } catch {
31
+ if !(error is CancellationError || Task.isCancelled) {
32
+ logger.error("invalidateAllContent: failed to refresh \(path): \(error.localizedDescription)")
33
+ }
34
+ return nil
36
35
  }
37
36
  }
38
- for load in loads {
39
- guard let resolved = await load.1.value, !Task.isCancelled else { continue }
40
- updateTemplate(load.0, with: resolved)
37
+ guard !Task.isCancelled else { return }
38
+ for ((template, _), page) in zip(targets, pages) {
39
+ guard let page else { continue }
40
+ updateTemplate(template, with: page)
41
41
  }
42
42
  }
43
43
  }
@@ -39,9 +39,9 @@ extension CarPlaySession {
39
39
  // A tab bar with no tabs renders blank. A gate shows one full-screen page instead;
40
40
  // otherwise it is the empty-content error, like any other failure (ADR 0001).
41
41
  guard !tabs.isEmpty else {
42
- if gated, let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: .browse, path: nil, search: nil)) {
42
+ if gated, let chrome = await browseGates(for: [nil])[0] {
43
43
  guard isCurrent(build) else { return }
44
- audioBrowser.onGate(GateEvent(reason: .browse))
44
+ reportGates([chrome], reason: .browse)
45
45
  interfaceController.safeSetRoot(makeGatePage(chrome, path: nil, tab: nil), animated: true)
46
46
  } else {
47
47
  await showRootError(.code(.emptyContent), build: build)
@@ -49,11 +49,9 @@ extension CarPlaySession {
49
49
  return
50
50
  }
51
51
 
52
- let chromes = gated ? await gates(for: tabs) : tabs.map { _ in nil }
52
+ let chromes = gated ? await browseGates(for: tabs.map(\.path)) : tabs.map { _ in nil }
53
53
  guard isCurrent(build) else { return }
54
- for _ in chromes.compactMap(\.self) {
55
- audioBrowser.onGate(GateEvent(reason: .browse))
56
- }
54
+ reportGates(chromes, reason: .browse)
57
55
  let templates = zip(tabs, chromes).map { tab, chrome in
58
56
  chrome.map { makeGatePage($0, path: tab.path, tab: tab) } ?? makeTabTemplate(for: tab)
59
57
  }
@@ -123,24 +121,24 @@ extension CarPlaySession {
123
121
  /// request is allowed.
124
122
  func gate(_ reason: GateReason, path: String?, search: SearchParams? = nil) async -> Gate? {
125
123
  let chrome = await audioBrowser.browseGate.decide(NativeGateRequest(reason: reason, path: path, search: search))
126
- if chrome != nil { audioBrowser.onGate(GateEvent(reason: reason)) }
124
+ reportGates([chrome], reason: reason)
127
125
  return chrome
128
126
  }
129
127
 
130
- /// One decision per tab, made concurrently and returned in tab order. Reports nothing: the
131
- /// caller fires `onGate` once it knows its build is still current.
132
- private func gates(for tabs: [Track]) async -> [Gate?] {
128
+ /// One browse decision per path, made concurrently and returned in path order. Reports
129
+ /// nothing: the caller reports once it knows its build is still current.
130
+ private func browseGates(for paths: [String?]) async -> [Gate?] {
133
131
  let browseGate = audioBrowser.browseGate
134
- let decisions = tabs.map { tab in
135
- Task<Gate?, Never> { @MainActor in
136
- await browseGate.decide(NativeGateRequest(reason: .browse, path: tab.path, search: nil))
137
- }
132
+ return await paths.concurrentMap { path in
133
+ await browseGate.decide(NativeGateRequest(reason: .browse, path: path, search: nil))
138
134
  }
139
- var chromes: [Gate?] = []
140
- for decision in decisions {
141
- await chromes.append(decision.value)
135
+ }
136
+
137
+ /// Fires `onGate` once for each request the gate gated.
138
+ private func reportGates(_ chromes: [Gate?], reason: GateReason) {
139
+ for _ in chromes.compactMap(\.self) {
140
+ audioBrowser.onGate(GateEvent(reason: reason))
142
141
  }
143
- return chromes
144
142
  }
145
143
 
146
144
  /// A tab's content shell, which loads when it first appears.
@@ -168,15 +166,16 @@ extension CarPlaySession {
168
166
  func applyTabBarEntry(to template: CPTemplate, for track: Track) {
169
167
  template.tabTitle = track.title
170
168
  if let artwork = track.artwork?.url, SFSymbolRenderer.isSFSymbol(artwork) {
169
+ // A plain symbol: CarPlay tints the tab image itself.
171
170
  let (symbolName, _, _) = SFSymbolRenderer.parseArtwork(artwork)
172
- if let image = imageLoader.sfSymbolImage(symbolName) {
171
+ if let image = UIImage(systemName: symbolName) {
173
172
  template.tabImage = image
174
173
  }
175
174
  } else if track.artwork != nil || track.artworkSource != nil {
176
- imageLoader.loadArtwork(for: track, size: CGSize(width: 24, height: 24)) { [weak template] image in
177
- Task { @MainActor in
178
- if let image { template?.tabImage = image }
179
- }
175
+ spawn { [weak template] session in
176
+ let image = await session.imageLoader.artwork(for: track, size: CGSize(width: 24, height: 24))
177
+ guard !Task.isCancelled, let image else { return }
178
+ template?.tabImage = image
180
179
  }
181
180
  } else {
182
181
  // A restamp must not leave an old icon on a now title-only tab.
@@ -15,7 +15,14 @@ final class CarPlaySession {
15
15
  var player: Player? { audioBrowser.player }
16
16
  let trackSelector: TrackSelector
17
17
  let imageLoader: CarPlayImageLoader
18
- let listItemFactory: CarPlayListItemFactory
18
+ private(set) lazy var listItemFactory = CarPlayListItemFactory(
19
+ imageLoader: imageLoader,
20
+ playingIndicatorLocation: playingIndicatorLocation,
21
+ isActiveTrack: { [weak self] identity in identity != nil && self?.player?.currentTrack?.identity == identity },
22
+ spawn: { [weak self] work in self?.spawn { _ in await work() } },
23
+ onItemSelected: { [weak self] track, completion in self?.select(track, completion: completion) },
24
+ onNavigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
25
+ )
19
26
  private(set) var nowPlaying: CarPlayNowPlayingManager!
20
27
 
21
28
  private var subscriptions: [Subscription] = []
@@ -56,12 +63,6 @@ final class CarPlaySession {
56
63
  carTraitCollection: interfaceController.carTraitCollection,
57
64
  browserManager: audioBrowser.browserManager,
58
65
  )
59
- listItemFactory = CarPlayListItemFactory(
60
- isActiveTrack: { [weak audioBrowser] identity in
61
- identity != nil && audioBrowser?.player?.currentTrack?.identity == identity
62
- },
63
- )
64
- listItemFactory.imageLoader = imageLoader
65
66
  nowPlaying = CarPlayNowPlayingManager(
66
67
  interfaceController: interfaceController,
67
68
  audioBrowser: audioBrowser,
@@ -70,9 +71,6 @@ final class CarPlaySession {
70
71
  },
71
72
  navigate: { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } },
72
73
  )
73
- listItemFactory.onItemSelected = { [weak self] track, completion in self?.select(track, completion: completion) }
74
- listItemFactory.onNavigate = { [weak self] path, title in self?.spawn { await $0.navigate(to: path, title: title) } }
75
- listItemFactory.playingIndicatorLocation = playingIndicatorLocation
76
74
  }
77
75
 
78
76
  func start() {
@@ -0,0 +1,96 @@
1
+ import Foundation
2
+ import Intents
3
+ import os.log
4
+
5
+ /// The library's Siri handlers, vended to the host app through `RNABAudioBrowser.handlerForIntent:`.
6
+ @objc(RNABIntentHandlers)
7
+ public final class IntentHandlers: NSObject {
8
+ /// A handler for `INPlayMediaIntent`, `INUpdateMediaAffinityIntent` and `INAddMediaIntent`;
9
+ /// nil for any other intent.
10
+ @objc(handlerForIntent:)
11
+ public static func handler(for intent: INIntent) -> Any? {
12
+ switch intent {
13
+ case is INPlayMediaIntent: MediaIntentHandler()
14
+ case is INUpdateMediaAffinityIntent, is INAddMediaIntent: FavoriteIntentHandler()
15
+ default: nil
16
+ }
17
+ }
18
+ }
19
+
20
+ /// "Add this to my favorites" and "I like / don't like this": sets the favorite state of the
21
+ /// playing track, through the same path as the CarPlay favorite button. A named item that is
22
+ /// not playing is out of scope.
23
+ final class FavoriteIntentHandler: NSObject, INAddMediaIntentHandling, INUpdateMediaAffinityIntentHandling {
24
+ private static let logger = Logger(subsystem: "com.audiobrowser", category: "FavoriteIntentHandler")
25
+
26
+ func handle(intent _: INAddMediaIntent, completion: @escaping @Sendable (INAddMediaIntentResponse) -> Void) {
27
+ Self.logger.info("Add media — favorite current track")
28
+ Task { @MainActor in
29
+ let applied = Self.favoriteCurrentTrack(true)
30
+ completion(INAddMediaIntentResponse(code: applied ? .success : .failure, userActivity: nil))
31
+ }
32
+ }
33
+
34
+ func handle(
35
+ intent: INUpdateMediaAffinityIntent,
36
+ completion: @escaping @Sendable (INUpdateMediaAffinityIntentResponse) -> Void,
37
+ ) {
38
+ let affinity = intent.affinityType
39
+ Self.logger.info("Update media affinity — type=\(affinity.rawValue)")
40
+ Task { @MainActor in
41
+ let applied = switch affinity {
42
+ case .like: Self.favoriteCurrentTrack(true)
43
+ case .dislike: Self.favoriteCurrentTrack(false)
44
+ default: false
45
+ }
46
+ completion(INUpdateMediaAffinityIntentResponse(code: applied ? .success : .failure, userActivity: nil))
47
+ }
48
+ }
49
+
50
+ // Siri fails an intent whose handler has no resolution method. They are optional @objc
51
+ // requirements, so each is pinned to the selector SiriKit calls. The handlers act on the
52
+ // playing track, so the spoken item passes through.
53
+
54
+ @objc(resolveMediaItemsForAddMedia:withCompletion:)
55
+ func resolveMediaItems(
56
+ for intent: INAddMediaIntent,
57
+ with completion: @escaping ([INAddMediaMediaItemResolutionResult]) -> Void,
58
+ ) {
59
+ completion([.success(with: Self.spokenItem(intent.mediaSearch))])
60
+ }
61
+
62
+ /// One flat favorites collection: always the user's library.
63
+ @objc(resolveMediaDestinationForAddMedia:withCompletion:)
64
+ func resolveMediaDestination(
65
+ for _: INAddMediaIntent,
66
+ with completion: @escaping (INAddMediaMediaDestinationResolutionResult) -> Void,
67
+ ) {
68
+ completion(.success(with: INMediaDestination.library))
69
+ }
70
+
71
+ @objc(resolveMediaItemsForUpdateMediaAffinity:withCompletion:)
72
+ func resolveMediaItems(
73
+ for intent: INUpdateMediaAffinityIntent,
74
+ with completion: @escaping ([INUpdateMediaAffinityMediaItemResolutionResult]) -> Void,
75
+ ) {
76
+ completion([.success(with: Self.spokenItem(intent.mediaSearch))])
77
+ }
78
+
79
+ private static func spokenItem(_ search: INMediaSearch?) -> INMediaItem {
80
+ let name = search?.mediaName ?? ""
81
+ return INMediaItem(identifier: name, title: name, type: .unknown, artwork: nil)
82
+ }
83
+
84
+ /// False when nothing is playing, the playing track has no identity to favorite, or the
85
+ /// change throws.
86
+ @MainActor
87
+ private static func favoriteCurrentTrack(_ favorited: Bool) -> Bool {
88
+ guard let browser = HybridAudioBrowser.shared else { return false }
89
+ do {
90
+ return try browser.favoriteActiveTrack(favorited)
91
+ } catch {
92
+ logger.error("favoriteCurrentTrack(\(favorited)) failed: \(error.localizedDescription)")
93
+ return false
94
+ }
95
+ }
96
+ }
@@ -3,7 +3,7 @@ import Foundation
3
3
  /// Normalized "what did the user ask to play", derived from a media intent.
4
4
  /// Deliberately free of `Intents` types so the core (`HybridAudioBrowser`)
5
5
  /// never imports the Intents framework — the mapping lives in the
6
- /// `Intents`-aware `RNABMediaIntentHandler`.
6
+ /// `Intents`-aware `MediaIntentHandler`.
7
7
  public struct MediaIntentCriteria: Sendable {
8
8
  /// Media-reference axis (mirrors `INMediaReference`). `currentlyPlaying`
9
9
  /// routes to native resume and never reaches the consumer; `my` routes to
@@ -12,19 +12,16 @@ public struct MediaIntentCriteria: Sendable {
12
12
 
13
13
  let query: String
14
14
  let reference: Reference
15
- let hasGenres: Bool
16
15
  let hasMediaType: Bool
17
- /// True when `query` is effectively the host app's own name. Siri turns
18
- /// "Play «app»" into a search for a word in the app name — e.g. "Play Radio
19
- /// Garden" arrives as mediaName "Garden" (+ radio mediaType). That's an
20
- /// app-open/resume, not a station search.
16
+ /// True when `query` is effectively the host app's own name. Siri turns "Play «App Name»"
17
+ /// into a search for a word of it ("Name", with a radio media type): an app-open or resume,
18
+ /// not a search.
21
19
  let matchesAppName: Bool
22
20
 
23
- // Structured search payload, mirroring the shared `SearchParams`. Plain
24
- // Sendable strings so this type stays in the unit-testable target; the Nitro
25
- // `SearchParams` is assembled in the funnel.
26
- /// The container-vertical `SearchMode` name, or nil. Comes ONLY from the
27
- /// intent's media type — never derived from which filter field is set.
21
+ // Structured search payload, mirroring `SearchParams` in plain strings so this type stays in
22
+ // the SwiftPM target; the funnel builds the Nitro `SearchParams`.
23
+ /// The container-vertical `SearchMode` name, or nil. Comes only from the intent's media type,
24
+ /// never from which filter field is set.
28
25
  let searchMode: String?
29
26
  let genre: String?
30
27
  let artist: String?
@@ -32,30 +29,40 @@ public struct MediaIntentCriteria: Sendable {
32
29
  let title: String?
33
30
  let playlist: String?
34
31
 
32
+ /// Builds criteria from the raw fields of a media-search intent.
33
+ ///
34
+ /// `mediaTypeMode` is the `SearchMode` string for the intent's container vertical ("station",
35
+ /// "podcast", "song", …), or nil for a filter-only or unclassified type.
35
36
  init(
36
- query: String,
37
+ mediaName: String?,
38
+ genreNames: [String],
39
+ artistName: String?,
40
+ albumName: String?,
41
+ mediaTypeMode: String?,
37
42
  reference: Reference,
38
- hasGenres: Bool,
39
43
  hasMediaType: Bool,
40
- matchesAppName: Bool,
41
- searchMode: String? = nil,
42
- genre: String? = nil,
43
- artist: String? = nil,
44
- album: String? = nil,
45
- title: String? = nil,
46
- playlist: String? = nil,
44
+ appName: String?,
47
45
  ) {
48
- self.query = query
46
+ let name = (mediaName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
47
+ let trimmedNonEmpty: (String?) -> String? = {
48
+ let t = ($0 ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
49
+ return t.isEmpty ? nil : t
50
+ }
51
+
52
+ genre = genreNames.isEmpty ? nil : genreNames.joined(separator: " ")
53
+ artist = trimmedNonEmpty(artistName)
54
+ album = trimmedNonEmpty(albumName)
55
+ // song/playlist carry their spoken name into a dedicated field.
56
+ title = mediaTypeMode == "song" ? (name.isEmpty ? nil : name) : nil
57
+ playlist = mediaTypeMode == "playlist" ? (name.isEmpty ? nil : name) : nil
58
+
59
+ // Always populated, so search works before the API honours `mode`: the spoken name, else a
60
+ // structured value.
61
+ query = name.isEmpty ? (genre ?? artist ?? album ?? title ?? playlist ?? "") : name
49
62
  self.reference = reference
50
- self.hasGenres = hasGenres
51
63
  self.hasMediaType = hasMediaType
52
- self.matchesAppName = matchesAppName
53
- self.searchMode = searchMode
54
- self.genre = genre
55
- self.artist = artist
56
- self.album = album
57
- self.title = title
58
- self.playlist = playlist
64
+ matchesAppName = Self.queryMatchesAppName(query, appName: appName)
65
+ searchMode = mediaTypeMode
59
66
  }
60
67
 
61
68
  /// Resume vs search, by reference:
@@ -69,61 +76,12 @@ public struct MediaIntentCriteria: Sendable {
69
76
  case .unknown:
70
77
  let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
71
78
  // "Play «app»": no search term and no other filter → resume.
72
- if q.isEmpty { return !hasGenres && !hasMediaType }
79
+ if q.isEmpty { return genre == nil && !hasMediaType }
73
80
  // "Play «app-name»": resume — unless a real filter signals a search.
74
- return matchesAppName && !hasGenres
81
+ return matchesAppName && genre == nil
75
82
  }
76
83
  }
77
84
 
78
- /// Builds criteria from the raw fields of a media-search intent. Pure (no
79
- /// `Intents`/`Bundle` dependency) so the whole Siri-phrase → search decision
80
- /// is unit-testable.
81
- ///
82
- /// `mediaTypeMode` is the already-collapsed `SearchMode` string for the
83
- /// intent's container vertical (e.g. "station"/"podcast"/"song"), or nil for
84
- /// a filter-only / unclassified type. `reference` is the mapped axis.
85
- static func from(
86
- mediaName: String?,
87
- genreNames: [String],
88
- artistName: String?,
89
- albumName: String?,
90
- mediaTypeMode: String?,
91
- reference: Reference,
92
- hasMediaType: Bool,
93
- appName: String?,
94
- ) -> MediaIntentCriteria {
95
- let name = (mediaName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
96
- let trimmedNonEmpty: (String?) -> String? = {
97
- let t = ($0 ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
98
- return t.isEmpty ? nil : t
99
- }
100
-
101
- let genre = genreNames.isEmpty ? nil : genreNames.joined(separator: " ")
102
- let artist = trimmedNonEmpty(artistName)
103
- let album = trimmedNonEmpty(albumName)
104
- // song/playlist carry their spoken name into a dedicated field.
105
- let title = mediaTypeMode == "song" ? (name.isEmpty ? nil : name) : nil
106
- let playlist = mediaTypeMode == "playlist" ? (name.isEmpty ? nil : name) : nil
107
-
108
- // `query` is always populated so search works even before the API honours
109
- // `mode`: prefer the spoken name, else fall back to a structured value.
110
- let query = name.isEmpty ? (genre ?? artist ?? album ?? title ?? playlist ?? "") : name
111
-
112
- return MediaIntentCriteria(
113
- query: query,
114
- reference: reference,
115
- hasGenres: !genreNames.isEmpty,
116
- hasMediaType: hasMediaType,
117
- matchesAppName: queryMatchesAppName(query, appName: appName),
118
- searchMode: mediaTypeMode, // mode is the vertical, verbatim — no derivation
119
- genre: genre,
120
- artist: artist,
121
- album: album,
122
- title: title,
123
- playlist: playlist,
124
- )
125
- }
126
-
127
85
  /// Whether `query` is effectively the host app's own name. Case- and
128
86
  /// diacritic-insensitive.
129
87
  ///
@@ -2,18 +2,15 @@ import Foundation
2
2
  import Intents
3
3
  import os.log
4
4
 
5
- /// In-app INPlayMediaIntent handler (Siri / CarPlay voice). Internal so Intents
6
- /// types stay out of the generated header; exposed to the ObjC runtime via
7
- /// `@objc(RNABMediaIntentHandler)` and vended from `RNABAudioBrowser.handlerForIntent(_:)`.
8
- @objc(RNABMediaIntentHandler)
9
- class RNABMediaIntentHandler: NSObject, INPlayMediaIntentHandling {
5
+ /// Siri's play-media intent, from the phone or CarPlay voice: plays through `MediaIntentPlayback`.
6
+ final class MediaIntentHandler: NSObject, INPlayMediaIntentHandling {
10
7
  private static let logger = Logger(subsystem: "com.audiobrowser", category: "MediaIntentHandler")
11
8
 
12
9
  // MARK: - INPlayMediaIntentHandling
13
10
 
14
11
  func handle(intent: INPlayMediaIntent, completion: @escaping @Sendable (INPlayMediaIntentResponse) -> Void) {
15
12
  let s = intent.mediaSearch
16
- let criteria = MediaIntentCriteria.from(
13
+ let criteria = MediaIntentCriteria(
17
14
  mediaName: s?.mediaName,
18
15
  genreNames: s?.genreNames ?? [],
19
16
  artistName: s?.artistName,
@@ -0,0 +1,35 @@
1
+ /// What CarPlay's Now Playing custom buttons show and do.
2
+ enum NowPlayingButtonRules {
3
+ /// `CPNowPlayingTemplate` takes at most five custom buttons and does not say what happens to
4
+ /// more.
5
+ static let maximumButtonCount = 5
6
+
7
+ /// The configured buttons, first occurrence of each kept, capped at `maximumButtonCount`:
8
+ /// the configuration comes from JS, where duplicates and excess are representable.
9
+ static func buttons<Button: Equatable>(_ configured: [Button]) -> [Button] {
10
+ var buttons: [Button] = []
11
+ for button in configured where !buttons.contains(button) {
12
+ buttons.append(button)
13
+ }
14
+ return Array(buttons.prefix(maximumButtonCount))
15
+ }
16
+
17
+ /// The repeat button cycles off → track → queue → off.
18
+ static func repeatMode(after mode: RepeatMode) -> RepeatMode {
19
+ switch mode {
20
+ case .off: .track
21
+ case .track: .queue
22
+ case .queue: .off
23
+ }
24
+ }
25
+
26
+ /// The rate after `current` in `rates`: the next one, wrapping, when `current` is one of them;
27
+ /// else the first rate above it, else the first. Nil when there are no rates.
28
+ static func rate(after current: Double, in rates: [Double]) -> Double? {
29
+ guard !rates.isEmpty else { return nil }
30
+ if let index = rates.firstIndex(where: { (current - $0).magnitude < 0.01 }) {
31
+ return rates[(index + 1) % rates.count]
32
+ }
33
+ return rates.first { $0 > current } ?? rates[0]
34
+ }
35
+ }
@@ -1,23 +1,15 @@
1
1
  #import "RNABAudioBrowser.h"
2
2
 
3
+ #if __has_include(<AudioBrowser/AudioBrowser-Swift.h>)
4
+ #import <AudioBrowser/AudioBrowser-Swift.h>
5
+ #elif __has_include("AudioBrowser-Swift.h")
6
+ #import "AudioBrowser-Swift.h"
7
+ #endif
8
+
3
9
  @implementation RNABAudioBrowser
4
10
 
5
11
  + (nullable id)handlerForIntent:(INIntent *)intent {
6
- // The handlers are internal Swift classes exposed to the ObjC runtime via
7
- // @objc(...); each conforms to the matching INIntentHandling protocol.
8
- NSString *handlerClassName = nil;
9
- if ([intent isKindOfClass:[INPlayMediaIntent class]]) {
10
- handlerClassName = @"RNABMediaIntentHandler";
11
- } else if ([intent isKindOfClass:[INUpdateMediaAffinityIntent class]]) {
12
- handlerClassName = @"RNABMediaAffinityHandler";
13
- } else if ([intent isKindOfClass:[INAddMediaIntent class]]) {
14
- handlerClassName = @"RNABMediaAddHandler";
15
- }
16
- if (!handlerClassName) {
17
- return nil;
18
- }
19
- Class cls = NSClassFromString(handlerClassName);
20
- return cls ? [[cls alloc] init] : nil;
12
+ return [RNABIntentHandlers handlerForIntent:intent];
21
13
  }
22
14
 
23
15
  @end
@@ -927,6 +927,25 @@ final class Player {
927
927
  }
928
928
  }
929
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
+
930
949
  // MARK: - Progress Updates
931
950
 
932
951
  func setProgressUpdateInterval(_ interval: TimeInterval?) {
@@ -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
+ }