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
@@ -13,6 +13,7 @@ final class AVPlayerEngine: PlayerEngine {
13
13
 
14
14
  var onEvent: (@MainActor (EngineEvent) -> Void)?
15
15
  private weak var callbacks: AVPlayerEngineCallbacks?
16
+ private weak var audioSession: AudioSessionController?
16
17
 
17
18
  let nowPlayingInfoController: NowPlayingInfoController
18
19
  let nowPlayingUpdater: NowPlayingUpdater
@@ -25,25 +26,40 @@ final class AVPlayerEngine: PlayerEngine {
25
26
  private var itemObserver: ItemObserver?
26
27
  private var rate: Float = 1
27
28
  private var skipAvailability = (canNext: false, canPrevious: false)
28
-
29
- var remoteCommands: [RemoteCommand] = [] {
30
- didSet { remoteCommandController.enable(commands: remoteCommands) }
31
- }
32
-
33
- /// - Parameter resolver: Resolves media URLs and now-playing artwork.
29
+ /// The charset of the track last loaded, for its ICY titles.
30
+ private var icyCharset: String?
31
+ private let bufferDuration: Double
32
+
33
+ /// - Parameters:
34
+ /// - resolver: Resolves media URLs and now-playing artwork.
35
+ /// - bufferDuration: 0 lets AVPlayer choose, minimising stalls.
36
+ /// - nowPlayingEnabled: When false the now-playing surface ignores the override and formatter.
34
37
  init(
35
38
  callbacks: AVPlayerEngineCallbacks?,
36
39
  resolver: MediaResolving?,
40
+ audioSession: AudioSessionController,
41
+ networkStatus: (any NetworkStatusProviding)?,
42
+ bufferDuration: Double,
43
+ nowPlayingEnabled: Bool,
44
+ nowPlayingFormatter: ((FormatNowPlayingParams) -> Promise<NowPlayingUpdate?>)?,
37
45
  nowPlayingInfoController: NowPlayingInfoController = NowPlayingInfoController(),
38
46
  ) {
39
47
  self.callbacks = callbacks
48
+ self.audioSession = audioSession
49
+ self.bufferDuration = bufferDuration
40
50
  self.nowPlayingInfoController = nowPlayingInfoController
41
- nowPlayingUpdater = NowPlayingUpdater(nowPlayingInfoController: nowPlayingInfoController)
51
+ nowPlayingUpdater = NowPlayingUpdater(
52
+ nowPlayingInfoController: nowPlayingInfoController,
53
+ networkStatus: networkStatus,
54
+ enabled: nowPlayingEnabled,
55
+ formatter: nowPlayingFormatter,
56
+ )
42
57
  remoteCommandController = RemoteCommandController(callbacks: callbacks)
43
58
  mediaLoader = MediaLoader { [weak resolver] src, track in
44
59
  await resolver?.resolveMediaUrl(src, track: track) ?? MediaResolvedUrl(url: src, headers: nil, userAgent: nil)
45
60
  }
46
61
 
62
+ mediaLoader.bufferDuration = bufferDuration
47
63
  mediaLoader.onEvent = { [weak self] in self?.mediaLoaderDidEmit($0) }
48
64
  nowPlayingUpdater.artworkUrlResolver = { [weak resolver] track, imageContext in
49
65
  await resolver?.resolveNowPlayingArtwork(track: track, imageContext: imageContext)
@@ -65,21 +81,6 @@ final class AVPlayerEngine: PlayerEngine {
65
81
  stopObservingItem()
66
82
  }
67
83
 
68
- /// Re-requests the now-playing election, which the system may have declined at link time while
69
- /// the audio session was still inactive.
70
- func reactivateNowPlayingSession() {
71
- nowPlayingInfoController.reactivateSessionIfNeeded()
72
- }
73
-
74
- // MARK: - Buffering
75
-
76
- var bufferDuration: Double = 0 {
77
- didSet {
78
- avPlayer.automaticallyWaitsToMinimizeStalling = bufferDuration == 0
79
- mediaLoader.bufferDuration = bufferDuration
80
- }
81
- }
82
-
83
84
  // MARK: - The Current Item
84
85
 
85
86
  var hasItem: Bool {
@@ -141,6 +142,7 @@ final class AVPlayerEngine: PlayerEngine {
141
142
  // MARK: - Loading
142
143
 
143
144
  func resolveAndLoad(src: String, track: Track) {
145
+ icyCharset = track.icyCharset
144
146
  mediaLoader.resolveAndLoad(src: src, track: track)
145
147
  }
146
148
 
@@ -221,6 +223,18 @@ final class AVPlayerEngine: PlayerEngine {
221
223
  nowPlayingUpdater.clearNowPlaying()
222
224
  }
223
225
 
226
+ func renderNowPlaying() {
227
+ nowPlayingUpdater.render()
228
+ }
229
+
230
+ func nowPlayingTrackChanged() {
231
+ nowPlayingUpdater.trackChanged()
232
+ }
233
+
234
+ func applyTimedMetadata(_ metadata: TimedMetadata) {
235
+ nowPlayingUpdater.receivedTimedMetadata(metadata)
236
+ }
237
+
224
238
  func updateRemoteRepeatMode(_ mode: RepeatMode) {
225
239
  remoteCommandController.updateRepeatMode(mode)
226
240
  }
@@ -234,6 +248,20 @@ final class AVPlayerEngine: PlayerEngine {
234
248
  remoteCommandController.refreshSkipAvailability()
235
249
  }
236
250
 
251
+ // MARK: - Audio Session
252
+
253
+ func activateAudioSession() {
254
+ audioSession?.activate { [weak self] in self?.nowPlayingInfoController.reactivateSessionIfNeeded() }
255
+ }
256
+
257
+ func cancelAudioSessionRelease() {
258
+ audioSession?.cancelRelease()
259
+ }
260
+
261
+ func scheduleAudioSessionRelease() {
262
+ audioSession?.scheduleRelease()
263
+ }
264
+
237
265
  // MARK: - Observed Events
238
266
 
239
267
  private func playerDidEmit(_ event: PlayerObserver.Event) {
@@ -260,7 +288,7 @@ final class AVPlayerEngine: PlayerEngine {
260
288
  case let .failed(error), let .failedToPlayToEnd(error): reportFailure(error)
261
289
  case .playedToEnd: onEvent?(.playedToEnd)
262
290
  case .stalled: onEvent?(.stalled)
263
- case let .timedMetadata(groups): callbacks?.playerDidReceiveTimedMetadata(groups)
291
+ case let .timedMetadata(groups): reportTimedMetadata(groups)
264
292
  }
265
293
  }
266
294
 
@@ -299,7 +327,15 @@ final class AVPlayerEngine: PlayerEngine {
299
327
  case let .chapterMetadata(groups):
300
328
  callbacks?.playerDidReceiveChapterMetadata(groups)
301
329
  case let .timedMetadata(groups):
302
- callbacks?.playerDidReceiveTimedMetadata(groups)
330
+ reportTimedMetadata(groups)
331
+ }
332
+ }
333
+
334
+ private func reportTimedMetadata(_ groups: [AVTimedMetadataGroup]) {
335
+ for group in groups {
336
+ if let metadata = TimedMetadata.from(items: group.items, icyCharset: icyCharset) {
337
+ onEvent?(.timedMetadata(metadata))
338
+ }
303
339
  }
304
340
  }
305
341
 
@@ -8,7 +8,6 @@ import NitroModules
8
8
  @MainActor
9
9
  protocol AVPlayerEngineCallbacks: RemoteCommandCallbacks {
10
10
  func playerDidReceiveCommonMetadata(_ metadata: [AVMetadataItem])
11
- func playerDidReceiveTimedMetadata(_ metadata: [AVTimedMetadataGroup])
12
11
  func playerDidReceiveChapterMetadata(_ metadata: [AVTimedMetadataGroup])
13
12
  /// The now-playing lines or track changed; carries the metadata JS sees.
14
13
  func playerDidChangeNowPlaying(_ metadata: NowPlayingMetadata)
@@ -111,13 +111,6 @@ extension CPInterfaceController {
111
111
  }
112
112
  }
113
113
 
114
- /// Pops the top template, skipping the raise case (nothing above the root).
115
- func safePop(animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
116
- whenStackSettled { controller in
117
- controller.guardedPop(animated: animated, completion: completion)
118
- }
119
- }
120
-
121
114
  /// Pops back to `template`, skipping the raise case (not in the hierarchy).
122
115
  /// A no-op success when it is already the top template.
123
116
  func safePop(to template: CPTemplate, animated: Bool, completion: ((Bool, Error?) -> Void)? = nil) {
@@ -8,12 +8,6 @@ import os.log
8
8
  @MainActor
9
9
  @objc(RNABCarPlayController)
10
10
  public final class RNABCarPlayController: NSObject {
11
- /// The Siri handlers `RNABAudioBrowser.m` looks up by name, referenced so a static link
12
- /// keeps them.
13
- @objc public static let siriIntentHandlerClasses: [AnyClass] = [
14
- RNABMediaIntentHandler.self, RNABMediaAffinityHandler.self, RNABMediaAddHandler.self,
15
- ]
16
-
17
11
  private let logger = Logger(subsystem: "com.audiobrowser", category: "CarPlayController")
18
12
  private let interfaceController: CPInterfaceController
19
13
  private var interfaceDelegate: InterfaceControllerDelegate?
@@ -6,7 +6,7 @@ import os.log
6
6
  /// Handles all image loading and rendering for CarPlay.
7
7
  ///
8
8
  /// Responsibilities:
9
- /// - SF Symbol rendering (plain and canvas-rendered with light/dark variants)
9
+ /// - SF Symbol rendering, with light/dark variants
10
10
  /// - Artwork loading via BrowserManager's artwork URL resolution
11
11
  /// - Direct artwork loading as fallback
12
12
  /// - Adaptive image tinting for monochrome icons
@@ -25,15 +25,10 @@ final class CarPlayImageLoader {
25
25
 
26
26
  // MARK: - SF Symbols
27
27
 
28
- /// Creates an SF Symbol image for tabs - plain systemName, CarPlay handles tinting
29
- func sfSymbolImage(_ symbolName: String) -> UIImage? {
30
- UIImage(systemName: symbolName)
31
- }
32
-
33
28
  /// Renders an SF Symbol from an artwork string (e.g. "sf:heart.fill?bg=#000&fg=#fff"). With an
34
29
  /// `fg` it is drawn in that color; without one it is black or white by appearance, over a grey
35
30
  /// tile.
36
- func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
31
+ private func sfSymbolImage(forArtwork artwork: String, canvasSize: CGSize) -> UIImage? {
37
32
  let (name, background, tint) = SFSymbolRenderer.parseArtwork(artwork)
38
33
  let scale = carTraitCollection.displayScale
39
34
  let lightTile = UIColor(white: 0.92, alpha: 1)
@@ -72,104 +67,56 @@ final class CarPlayImageLoader {
72
67
 
73
68
  // MARK: - Artwork Loading
74
69
 
75
- /// Loads artwork for a track with size context, using the artwork transform if configured.
76
- /// - Parameters:
77
- /// - track: The track to load artwork for
78
- /// - style: The track's resolved style block (inherited values folded in).
79
- /// nil = the track's own declaration — callers without a section context
80
- /// (tabs, now-playing) have no inheritance to resolve.
81
- /// - size: The target size in points (will be multiplied by CarPlay display scale)
82
- /// - completion: Called with the loaded image, or nil on failure
83
- func loadArtwork(for track: Track, style: TrackStyle? = nil, size: CGSize, completion: @escaping @Sendable (UIImage?) -> Void) {
84
- // A track that ships one image per appearance is loaded as two ordinary
85
- // single-URL loads and combined below. Recursing rather than branching
86
- // inside the loader means each variant still goes through the whole
87
- // pipeline — route config, headers, SVG rasterising, the resolve transform
88
- // — instead of a second, thinner fetch path that would drift from it.
70
+ /// A track's artwork at `size` points, through the artwork transform when one is configured;
71
+ /// nil when it has none or the load fails.
72
+ /// - Parameter style: the track's resolved style block; nil for the track's own declaration,
73
+ /// when there is no section to inherit from (tabs, Up Next).
74
+ func artwork(for track: Track, style: TrackStyle? = nil, size: CGSize) async -> UIImage? {
75
+ // Each variant goes through the whole single-URL path (route config, headers, SVG, the
76
+ // resolve transform), so the pair cannot drift from it.
89
77
  if let variants = track.artwork?.variants {
90
- loadVariants(for: track, style: style, variants: variants, size: size, completion: completion)
91
- return
78
+ return await artwork(for: track, style: style, variants: variants, size: size)
92
79
  }
93
80
 
94
81
  let artwork = track.artwork?.url
95
- Task {
96
- var resolved: ArtworkResolvedImage?
97
- if CarPlayArtworkResolver.needsResolve(artwork: artwork), let browserManager = self.browserManager {
98
- let scale = Double(self.carTraitCollection.displayScale)
99
- let context = ImageContext(width: Double(size.width) * scale, height: Double(size.height) * scale)
100
- resolved = await browserManager.requests.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: context)
101
- .map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) }
102
- }
103
- let action = CarPlayArtworkResolver.action(
104
- artwork: artwork,
105
- artworkSource: track.artworkSource.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) },
106
- resolved: resolved,
107
- stencil: (style ?? track.style)?.artworkRendering == .stencil,
108
- width: Double(size.width),
109
- height: Double(size.height),
110
- )
82
+ var resolved: ArtworkResolvedImage?
83
+ if CarPlayArtworkResolver.needsResolve(artwork: artwork), let browserManager {
84
+ let scale = Double(carTraitCollection.displayScale)
85
+ let context = ImageContext(width: Double(size.width) * scale, height: Double(size.height) * scale)
86
+ resolved = await browserManager.requests.resolveArtworkUrl(track: track, perRouteConfig: nil, imageContext: context)
87
+ .map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) }
88
+ }
89
+ let action = CarPlayArtworkResolver.action(
90
+ artwork: artwork,
91
+ artworkSource: track.artworkSource.map { ArtworkResolvedImage(uri: $0.uri, headers: $0.headers) },
92
+ resolved: resolved,
93
+ stencil: (style ?? track.style)?.artworkRendering == .stencil,
94
+ width: Double(size.width),
95
+ height: Double(size.height),
96
+ )
111
97
 
112
- switch action {
113
- case let .sfSymbol(artwork, width, height):
114
- completion(self.sfSymbolImage(forArtwork: artwork, canvasSize: CGSize(width: width, height: height)))
115
- case let .fetch(uri, headers, shouldTint, isSvg):
116
- let image = await self.fetchImage(uri: uri, headers: headers, isSvg: isSvg, size: size)
117
- if let image, shouldTint {
118
- completion(self.createAdaptiveImage(image, carTraitCollection: self.carTraitCollection))
119
- } else {
120
- completion(image)
121
- }
122
- case .none:
123
- completion(nil)
124
- }
98
+ switch action {
99
+ case let .sfSymbol(artwork, width, height):
100
+ return sfSymbolImage(forArtwork: artwork, canvasSize: CGSize(width: width, height: height))
101
+ case let .fetch(uri, headers, shouldTint, isSvg):
102
+ let image = await fetchImage(uri: uri, headers: headers, isSvg: isSvg, size: size)
103
+ guard let image, shouldTint else { return image }
104
+ return tinted(image)
105
+ case .none:
106
+ return nil
125
107
  }
126
108
  }
127
109
 
128
110
  // MARK: - Private
129
111
 
130
- /// Loads both images of an `ArtworkVariants` pair and returns them as one
131
- /// adaptive image.
132
- ///
133
- /// The pair is registered on a `UIImageAsset`, which is what makes an
134
- /// appearance change mid-drive re-resolve in place: CarPlay re-reads the
135
- /// asset against the new trait collection, so the row swaps image without
136
- /// another fetch or a re-query of the browse tree.
137
- ///
138
- /// If either half fails to load, the other is used alone rather than dropping
139
- /// the row's image entirely — a glyph that is wrong for one appearance still
140
- /// beats an empty slot, and this is usually a transient fetch failure.
141
- private func loadVariants(
142
- for track: Track,
143
- style: TrackStyle?,
144
- variants: ArtworkVariants,
145
- size: CGSize,
146
- completion: @escaping @Sendable (UIImage?) -> Void,
147
- ) {
148
- Task {
149
- async let light = self.loadArtworkAsync(
150
- for: track.copying(artwork: .some(.first(variants.light))), style: style, size: size,
151
- )
152
- async let dark = self.loadArtworkAsync(
153
- for: track.copying(artwork: .some(.first(variants.dark))), style: style, size: size,
154
- )
155
- let (lightImage, darkImage) = await (light, dark)
156
-
157
- guard let lightImage, let darkImage else {
158
- completion(lightImage ?? darkImage)
159
- return
160
- }
161
-
162
- completion(.adaptive(light: lightImage, dark: darkImage, for: self.carTraitCollection))
163
- }
164
- }
165
-
166
- /// `loadArtwork` as an awaitable, so the two variants can load concurrently.
167
- private func loadArtworkAsync(for track: Track, style: TrackStyle?, size: CGSize) async -> UIImage? {
168
- await withCheckedContinuation { continuation in
169
- self.loadArtwork(for: track, style: style, size: size) { image in
170
- continuation.resume(returning: image)
171
- }
172
- }
112
+ /// Both images of an `ArtworkVariants` pair as one adaptive image, which CarPlay re-resolves
113
+ /// in place when the appearance changes. When one half fails, the other is used alone.
114
+ private func artwork(for track: Track, style: TrackStyle?, variants: ArtworkVariants, size: CGSize) async -> UIImage? {
115
+ async let light = artwork(for: track.copying(artwork: .some(.first(variants.light))), style: style, size: size)
116
+ async let dark = artwork(for: track.copying(artwork: .some(.first(variants.dark))), style: style, size: size)
117
+ let (lightImage, darkImage) = await (light, dark)
118
+ guard let lightImage, let darkImage else { return lightImage ?? darkImage }
119
+ return .adaptive(light: lightImage, dark: darkImage, for: carTraitCollection)
173
120
  }
174
121
 
175
122
  /// Fetches an image from a URL with optional headers and SVG processing.
@@ -199,8 +146,8 @@ final class CarPlayImageLoader {
199
146
  return rendered.withRenderingMode(.alwaysOriginal)
200
147
  }
201
148
 
202
- /// Creates light/dark tinted variants of an image and returns the appropriate one for current appearance
203
- private nonisolated func createAdaptiveImage(_ image: UIImage, carTraitCollection: UITraitCollection) -> UIImage {
149
+ /// A monochrome image drawn black for light appearance and white for dark.
150
+ private func tinted(_ image: UIImage) -> UIImage {
204
151
  .adaptive(
205
152
  light: renderImageToBitmap(image, tintColor: .black),
206
153
  dark: renderImageToBitmap(image, tintColor: .white),