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
@@ -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,
@@ -69,6 +69,6 @@ enum MediaIntentPlayback {
69
69
  /// destroyed player.
70
70
  @MainActor
71
71
  private static func livePlayer(of browser: HybridAudioBrowser) -> Player? {
72
- HybridAudioBrowser.shared === browser ? browser.getPlayer() : nil
72
+ HybridAudioBrowser.shared === browser ? browser.player : nil
73
73
  }
74
74
  }
@@ -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
@@ -1,74 +1,27 @@
1
1
  import Foundation
2
- import NitroModules
3
2
 
4
- /// Extension to build RemoteCommands from PlayerCapabilities
5
- extension PlayerCapabilities {
6
- /// Build array of enabled RemoteCommands based on capabilities
7
- /// All capabilities are enabled by default - only false values disable them
8
- func buildRemoteCommands(
9
- forwardJumpInterval: NSNumber?,
10
- backwardJumpInterval: NSNumber?,
11
- playbackRates: [Double],
12
- ) -> [RemoteCommand] {
13
- var commands: [RemoteCommand] = []
14
-
15
- // Play/Pause/Stop
16
- if play != false {
17
- commands.append(.play)
18
- }
19
- if pause != false {
20
- commands.append(.pause)
21
- }
22
- if stop != false {
23
- commands.append(.stop)
24
- }
25
-
26
- // Navigation
27
- if skipToNext != false {
28
- commands.append(.next)
29
- }
30
- if skipToPrevious != false {
31
- commands.append(.previous)
32
- }
33
-
34
- // Seeking
35
- if seekTo != false {
36
- commands.append(.changePlaybackPosition)
37
- }
3
+ #if canImport(NitroModules)
4
+ import NitroModules
5
+ #endif
38
6
 
39
- // Jump forward/backward
40
- if jumpForward != false {
41
- commands.append(.skipForward(
42
- preferredIntervals: [(forwardJumpInterval ?? backwardJumpInterval) ?? 15],
43
- ))
44
- }
45
- if jumpBackward != false {
46
- commands.append(.skipBackward(
47
- preferredIntervals: [(backwardJumpInterval ?? forwardJumpInterval) ?? 15],
48
- ))
49
- }
50
-
51
- // NOTE: We deliberately do NOT register MPRemoteCommandCenter.likeCommand for the
52
- // `favorite` capability. That MPFeedbackCommand had a single system surface — a
53
- // pre-iOS-15 lock-screen menu shown in place of the previous-track button — which
54
- // Apple silently removed. On the iOS 16+ target it can never be invoked: CarPlay
55
- // uses its own CPNowPlayingButton heart, and Siri "I like this" routes through
56
- // INUpdateMediaAffinityIntent. The `favorite` capability still drives the
57
- // favorite row indicators via setFavoriteEnabled. See issues #67 / #71.
58
-
59
- // Mode controls
60
- if shuffleMode != false {
61
- commands.append(.changeShuffleMode)
62
- }
63
- if repeatMode != false {
64
- commands.append(.changeRepeatMode)
65
- }
66
- if playbackRate != false {
67
- commands.append(.changePlaybackRate(
68
- supportedPlaybackRates: playbackRates.map { NSNumber(value: $0) },
69
- ))
70
- }
71
-
72
- return commands
7
+ extension PlayerCapabilities {
8
+ /// The remote commands the capabilities enable: each one unless set to false. `favorite`
9
+ /// enables none: no iOS 16+ surface invokes `likeCommand`; CarPlay has its own heart button and
10
+ /// Siri its affinity intent.
11
+ var remoteCommands: Set<RemoteCommand> {
12
+ let enabled: [(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
+ return Set(enabled.compactMap { $0.0 == false ? nil : $0.1 })
73
26
  }
74
27
  }
@@ -4,22 +4,6 @@ import NitroModules
4
4
 
5
5
  /// Extension to make Nitro Track work with AVPlayer
6
6
  extension Track {
7
- /// The URL for AVPlayer playback (uses src for playback URL)
8
- var playbackUrl: URL? {
9
- guard let src else { return nil }
10
- return URL(string: src)
11
- }
12
-
13
- /// Whether this is a local file URL
14
- var isLocalFile: Bool {
15
- playbackUrl?.isFileURL ?? false
16
- }
17
-
18
- /// The source type (file or stream) based on the URL scheme
19
- var sourceType: SourceType {
20
- isLocalFile ? .file : .stream
21
- }
22
-
23
7
  /// Builds an `ImageSource` from the track's artwork fields, skipping SF Symbol strings.
24
8
  var artworkImageSource: ImageSource? {
25
9
  if let source = artworkSource {
@@ -10,12 +10,16 @@ public extension HybridAudioBrowser {
10
10
  /// synchronous: JS hears it before the navigation that may set the error again.
11
11
  internal func navigate(_ path: String, clearError: Bool) {
12
12
  if clearError { onMainActor { navigationError.clear() } }
13
- Task { @MainActor in
14
- do {
15
- try await browserManager.load(path)
16
- } catch {
17
- await reportNavigationError(error, path: path)
18
- }
13
+ Task { @MainActor in await loadPath(path) }
14
+ }
15
+
16
+ /// Loads `path` into the browser, reporting a failure as the navigation error.
17
+ @MainActor
18
+ private func loadPath(_ path: String) async {
19
+ do {
20
+ try await browserManager.load(path)
21
+ } catch {
22
+ await reportNavigationError(error, path: path)
19
23
  }
20
24
  }
21
25
 
@@ -33,9 +37,7 @@ public extension HybridAudioBrowser {
33
37
  }
34
38
 
35
39
  func navigateTrack(track: Track) throws {
36
- // @MainActor: `player` is main-confined, and `trackSelector.select` is
37
- // MainActor anyway — a bare Task read the reference off the cooperative
38
- // pool, racing the assignment in `setupPlayer`.
40
+ // @MainActor: `player` is main-confined.
39
41
  Task { @MainActor in
40
42
  guard let player else { return }
41
43
  let result = await trackSelector.select(track: track, player: player)
@@ -81,15 +83,12 @@ public extension HybridAudioBrowser {
81
83
  func notifyContentChanged(path: String) throws {
82
84
  // Throws on a contextual path (matching Android's `require`) — the caller
83
85
  // must pass the container path, or the invalidation silently hits nothing.
84
- try onMainActor { try browserManager.invalidateContentCache(path) }
85
-
86
- // Notify external controllers (CarPlay) that content changed
87
- externalContentChangedEmitter.emit(path)
88
-
89
- // Re-resolve the path if it's the current browser path
90
- if onMainActor({ browserManager.path }) == path {
91
- navigate(path, clearError: false)
86
+ let isCurrentPath = try onMainActor {
87
+ try browserManager.invalidateContentCache(path)
88
+ return browserManager.path == path
92
89
  }
90
+ externalContentChangedEmitter.emit(path)
91
+ if isCurrentPath { navigate(path, clearError: false) }
93
92
  }
94
93
 
95
94
  func invalidateAllContent() throws {