react-native-audio-browser 0.2.0-next.12 → 0.2.0-next.14

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 (185) hide show
  1. package/README.md +10 -0
  2. package/android/build.gradle +20 -1
  3. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +8 -1
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +52 -0
  5. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  6. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -11
  7. package/android/src/main/java/com/audiobrowser/browser/SectionScope.kt +1 -1
  8. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
  9. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
  10. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  11. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +21 -22
  12. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
  13. package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
  14. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  15. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
  16. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +16 -6
  17. package/android/src/main/res/values/strings.xml +37 -2
  18. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +50 -5
  19. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt +9 -0
  20. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
  21. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  22. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
  23. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
  24. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
  25. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
  26. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  27. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +10 -9
  28. package/ios/Browser/BrowserManager.swift +106 -45
  29. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  30. package/ios/Browser/JsonModels.swift +81 -15
  31. package/ios/Browser/SectionPresentation.swift +30 -0
  32. package/ios/Browser/StyleResolver.swift +11 -0
  33. package/ios/Browser/TabBarEntries.swift +7 -1
  34. package/ios/CarPlay/CarPlayController.swift +139 -35
  35. package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
  36. package/ios/Extension/Section+Copying.swift +1 -3
  37. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  38. package/ios/Http/HttpClient.swift +8 -2
  39. package/ios/HybridAudioBrowser.swift +56 -12
  40. package/ios/Model/NitroTypeStubs.swift +60 -1
  41. package/ios/Player/MediaIntentCriteria.swift +13 -5
  42. package/ios/Player/MediaLoader.swift +33 -9
  43. package/ios/Player/NowPlayingUpdater.swift +53 -5
  44. package/ios/Player/PlaybackCoordinator.swift +11 -4
  45. package/ios/Player/PlaybackTimer.swift +8 -2
  46. package/ios/Player/QueueManager.swift +36 -10
  47. package/ios/Player/ShuffleOrder.swift +23 -0
  48. package/ios/Player/SleepTimerHandling.swift +13 -0
  49. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  50. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  51. package/ios/Tests/JsonModelsTests.swift +76 -1
  52. package/ios/Tests/QueueManagerTests.swift +72 -6
  53. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  54. package/ios/Tests/ResolvedTrackCopyingTests.swift +2 -2
  55. package/ios/Tests/SectionPresentationTests.swift +29 -0
  56. package/ios/Tests/StyleResolverTests.swift +60 -0
  57. package/ios/Tests/Support/TestTypes.swift +7 -0
  58. package/ios/TrackPlayer.swift +57 -34
  59. package/ios/Util/NetworkMonitor.swift +43 -26
  60. package/ios/Util/SFSymbolRenderer.swift +24 -2
  61. package/ios/Util/SVGProcessor.swift +9 -2
  62. package/lib/commonjs/features/browser-config.js +1 -0
  63. package/lib/commonjs/features/browser-config.js.map +1 -1
  64. package/lib/commonjs/features/playback/progress.js +2 -1
  65. package/lib/commonjs/features/playback/progress.js.map +1 -1
  66. package/lib/commonjs/features/playback/rate.js +8 -1
  67. package/lib/commonjs/features/playback/rate.js.map +1 -1
  68. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  69. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  70. package/lib/module/features/browser-config.js +1 -0
  71. package/lib/module/features/browser-config.js.map +1 -1
  72. package/lib/module/features/playback/progress.js +2 -1
  73. package/lib/module/features/playback/progress.js.map +1 -1
  74. package/lib/module/features/playback/rate.js +8 -1
  75. package/lib/module/features/playback/rate.js.map +1 -1
  76. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  77. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  78. package/lib/typescript/src/features/browser-config.d.ts.map +1 -1
  79. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  80. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  81. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  82. package/lib/typescript/src/types/browser-native.d.ts +2 -1
  83. package/lib/typescript/src/types/browser-native.d.ts.map +1 -1
  84. package/lib/typescript/src/types/browser-nodes.d.ts +116 -9
  85. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  86. package/lib/typescript/src/types/browser.d.ts +36 -0
  87. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  88. package/nitrogen/generated/android/AudioBrowserOnLoad.cpp +2 -0
  89. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
  90. package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
  91. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  92. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
  93. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
  94. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
  95. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
  96. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
  97. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
  98. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
  99. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
  100. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__string__.hpp +102 -0
  101. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +6 -0
  102. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
  103. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
  104. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
  105. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
  106. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  107. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +13 -0
  108. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  109. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
  110. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +20 -1
  111. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
  112. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
  113. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
  114. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
  115. package/nitrogen/generated/android/c++/JSection.hpp +7 -5
  116. package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
  117. package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
  118. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
  119. package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
  120. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
  121. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  122. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Func_std__shared_ptr_Promise_std__string__.kt +80 -0
  123. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  124. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  125. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/NativeBrowserConfiguration.kt +7 -4
  126. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/Section.kt +2 -5
  127. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
  128. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
  129. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.cpp +9 -0
  130. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +104 -0
  131. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
  132. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
  133. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  134. package/nitrogen/generated/ios/swift/Func_std__shared_ptr_Promise_std__string__.swift +54 -0
  135. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  136. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  137. package/nitrogen/generated/ios/swift/NativeBrowserConfiguration.swift +48 -1
  138. package/nitrogen/generated/ios/swift/Section.swift +1 -19
  139. package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
  140. package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
  141. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  142. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  143. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  144. package/nitrogen/generated/shared/c++/NativeBrowserConfiguration.hpp +6 -2
  145. package/nitrogen/generated/shared/c++/Section.hpp +1 -5
  146. package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
  147. package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
  148. package/package.json +7 -2
  149. package/src/features/browser-config.ts +1 -0
  150. package/src/features/playback/progress.ts +2 -1
  151. package/src/features/playback/rate.ts +8 -1
  152. package/src/style-resolution.test.ts +72 -1
  153. package/src/types/browser-native.ts +5 -1
  154. package/src/types/browser-nodes.ts +124 -9
  155. package/src/types/browser.ts +38 -0
  156. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  157. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  158. package/website/guide/android-auto.md +114 -0
  159. package/website/guide/android-certificates.md +409 -0
  160. package/website/guide/artwork.md +255 -0
  161. package/website/guide/audio-output.md +135 -0
  162. package/website/guide/automotive.md +86 -0
  163. package/website/guide/basic-usage.md +151 -0
  164. package/website/guide/battery.md +115 -0
  165. package/website/guide/browser.md +581 -0
  166. package/website/guide/carplay.md +620 -0
  167. package/website/guide/configuration.md +173 -0
  168. package/website/guide/equalizer.md +231 -0
  169. package/website/guide/errors.md +310 -0
  170. package/website/guide/favorites.md +154 -0
  171. package/website/guide/gate.md +143 -0
  172. package/website/guide/getting-started.md +46 -0
  173. package/website/guide/hooks.md +238 -0
  174. package/website/guide/live-streams.md +125 -0
  175. package/website/guide/metadata.md +239 -0
  176. package/website/guide/migrating-from-track-player.md +244 -0
  177. package/website/guide/native-callback-fetch.md +127 -0
  178. package/website/guide/network.md +86 -0
  179. package/website/guide/now-playing.md +175 -0
  180. package/website/guide/playback.md +328 -0
  181. package/website/guide/queue.md +300 -0
  182. package/website/guide/remote-controls.md +256 -0
  183. package/website/guide/search.md +255 -0
  184. package/website/guide/sleep-timer.md +268 -0
  185. package/website/guide/track.md +246 -0
@@ -154,6 +154,17 @@ final class CarPlayListItemFactory {
154
154
  item.accessoryType = .disclosureIndicator
155
155
  }
156
156
 
157
+ // A resolved accessorySymbol replaces the derived accessory above
158
+ // (accessoryImage outranks accessoryType in the SDK). 'none' — the
159
+ // inheritance escape — keeps the derived behavior instead.
160
+ if let symbol = SectionPresentation.effectiveAccessorySymbol(style ?? track.style),
161
+ let accessory = UIImage(systemName: symbol)
162
+ {
163
+ // `accessoryImage` is readonly on CPListItem; post-init updates go
164
+ // through the setter (which also clears `accessoryType`, per the SDK).
165
+ item.setAccessoryImage(accessory)
166
+ }
167
+
157
168
  // An unavailable track grays out and goes inert — list rows are the
158
169
  // surface that CAN draw the fact, so it shows rather than hides
159
170
  // (Track.disabled's rendering ladder).
@@ -202,6 +213,18 @@ final class CarPlayListItemFactory {
202
213
  return CPListImageRowItem.maximumImageSize
203
214
  }
204
215
 
216
+ /// Target artwork size for one tile. Cards read their own class maximums
217
+ /// ('background' selects the larger full-height target); every other
218
+ /// family uses the shared element size.
219
+ private static func tileImageSize(gridTile: GridTile?, cardImage: CardImage?) -> CGSize {
220
+ if #available(iOS 26.0, *), gridTile == .card {
221
+ return cardImage == .background
222
+ ? CPListImageRowItemCardElement.maximumFullHeightImageSize
223
+ : CPListImageRowItemCardElement.maximumImageSize
224
+ }
225
+ return rowImageSize
226
+ }
227
+
205
228
  /// Creates the image-row item rendering a tile-presented section: a single
206
229
  /// truncating line (`.singleLineRow`) or a wrapping multi-line grid
207
230
  /// (`.wrappingGrid` — constructed on iOS 26+ only, by `SectionPresentation`).
@@ -237,42 +260,90 @@ final class CarPlayListItemFactory {
237
260
  // doesn't exist before iOS 26). nil = the legacy gridImages path.
238
261
  // @MainActor makes the closure Sendable for the artwork completion hop.
239
262
  var applyImage: (@MainActor (Int, UIImage) -> Void)?
263
+ // Resolved once per track, shared by element construction and the
264
+ // artwork loads below.
265
+ let resolvedStyles = tracks.map { StyleResolver.trackStyle(track: $0.style, section: style) }
240
266
  if #available(iOS 26.0, *) {
241
- if singleLine {
242
- // Row elements are the only tile element with a subtitle slot — the
243
- // imageGridElements branch below has no equivalent, by SDK design.
244
- let rowElements = tracks.map { track in
245
- let element = CPListImageRowItemRowElement(
246
- image: placeholder(), title: track.title, subtitle: track.subtitle,
247
- )
267
+ // Builds a family's elements with the shared per-element tail: the
268
+ // disabled fact (isEnabled lives on the element base class) applied
269
+ // once, not per family.
270
+ func makeElements<Element: CPListImageRowItemElement>(
271
+ _ make: (Track, TrackStyle) -> Element,
272
+ ) -> [Element] {
273
+ zip(tracks, resolvedStyles).map { track, resolved in
274
+ let element = make(track, resolved)
248
275
  if track.disabled == true {
249
276
  element.isEnabled = false
250
277
  }
251
278
  return element
252
279
  }
253
- item = CPListImageRowItem(text: section.title, elements: rowElements, allowsMultipleLines: false)
254
- applyImage = { index, image in
255
- guard index < rowElements.count else { return }
256
- rowElements[index].image = image
280
+ }
281
+ // The image setter every element family shares (`image` is on the base
282
+ // class); bounds-checked against the elements the row was built with.
283
+ func imageApplier(_ elements: [some CPListImageRowItemElement]) -> @MainActor (Int, UIImage) -> Void {
284
+ { index, image in
285
+ guard index < elements.count else { return }
286
+ elements[index].image = image
257
287
  }
258
- } else {
288
+ }
289
+ // The family is style-driven (`SectionPresentation.tileFamily`); both
290
+ // non-plain families take either wrap mode. Pre-26 the treatment drops
291
+ // and the layout survives (the legacy branch below).
292
+ switch SectionPresentation.tileFamily(for: style, singleLine: singleLine) {
293
+ case .cardElements:
294
+ // Cards have no shape and no accessory slot; their knobs are the
295
+ // tint and the image mode ('background' fills the card full-height
296
+ // and turns the tint into the color behind the labels).
297
+ let elements = makeElements { track, resolved in
298
+ CPListImageRowItemCardElement(
299
+ image: placeholder(),
300
+ showsImageFullHeight: resolved.cardImage == .background,
301
+ title: track.title,
302
+ subtitle: track.subtitle,
303
+ tintColor: resolved.cardTint.flatMap { UIColor(declaredHex: $0) },
304
+ )
305
+ }
306
+ item = CPListImageRowItem(
307
+ text: section.title, cardElements: elements, allowsMultipleLines: !singleLine,
308
+ )
309
+ applyImage = imageApplier(elements)
310
+ case .condensedElements:
311
+ let elements = makeElements { track, resolved in
312
+ CPListImageRowItemCondensedElement(
313
+ image: placeholder(),
314
+ imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
315
+ title: track.title,
316
+ subtitle: track.subtitle,
317
+ accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
318
+ )
319
+ }
320
+ item = CPListImageRowItem(
321
+ text: section.title, condensedElements: elements, allowsMultipleLines: !singleLine,
322
+ )
323
+ applyImage = imageApplier(elements)
324
+ case .rowElements:
325
+ // The only plain tile with a subtitle slot — the imageGridElements
326
+ // family has no equivalent, by SDK design.
327
+ let elements = makeElements { track, _ in
328
+ CPListImageRowItemRowElement(
329
+ image: placeholder(), title: track.title, subtitle: track.subtitle,
330
+ )
331
+ }
332
+ item = CPListImageRowItem(text: section.title, elements: elements, allowsMultipleLines: false)
333
+ applyImage = imageApplier(elements)
334
+ case .imageGridElements:
259
335
  // imageGridElements, never the title-less gridElements: an
260
336
  // artwork-less track must keep its name on screen (ADR 0010).
261
- let gridElements = tracks.map { track in
262
- let element = CPListImageRowItemImageGridElement(
263
- image: placeholder(), imageShape: .roundedRectangle, title: track.title,
264
- accessorySymbolName: nil,
337
+ let elements = makeElements { track, resolved in
338
+ CPListImageRowItemImageGridElement(
339
+ image: placeholder(),
340
+ imageShape: resolved.imageShape == .circular ? .circular : .roundedRectangle,
341
+ title: track.title,
342
+ accessorySymbolName: SectionPresentation.effectiveAccessorySymbol(resolved),
265
343
  )
266
- if track.disabled == true {
267
- element.isEnabled = false
268
- }
269
- return element
270
- }
271
- item = CPListImageRowItem(text: section.title, imageGridElements: gridElements, allowsMultipleLines: true)
272
- applyImage = { index, image in
273
- guard index < gridElements.count else { return }
274
- gridElements[index].image = image
275
344
  }
345
+ item = CPListImageRowItem(text: section.title, imageGridElements: elements, allowsMultipleLines: true)
346
+ applyImage = imageApplier(elements)
276
347
  }
277
348
  } else {
278
349
  let placeholders = tracks.map { _ in placeholder() }
@@ -310,10 +381,11 @@ final class CarPlayListItemFactory {
310
381
  for (index, track) in tracks.enumerated() {
311
382
  guard track.artwork != nil || track.artworkSource != nil else { continue }
312
383
 
384
+ let resolved = resolvedStyles[index]
313
385
  imageLoader?.loadArtwork(
314
386
  for: track,
315
- style: StyleResolver.trackStyle(track: track.style, section: style),
316
- size: Self.rowImageSize,
387
+ style: resolved,
388
+ size: Self.tileImageSize(gridTile: style.gridTile, cardImage: resolved.cardImage),
317
389
  ) { [weak item] image in
318
390
  Task { @MainActor in
319
391
  guard let item, let image else { return }
@@ -9,14 +9,12 @@ extension Section {
9
9
  /// `children` is single-optional since it is non-optional on Section.
10
10
  func copying(
11
11
  title: String?? = nil,
12
- subtitle: String?? = nil,
13
12
  style: SectionStyle?? = nil,
14
13
  path: String?? = nil,
15
14
  children: [Track]? = nil,
16
15
  ) -> Section {
17
16
  Section(
18
17
  title: title ?? self.title,
19
- subtitle: subtitle ?? self.subtitle,
20
18
  style: style ?? self.style,
21
19
  path: path ?? self.path,
22
20
  children: children ?? self.children,
@@ -26,6 +24,6 @@ extension Section {
26
24
  /// One untitled section — the canonical wrap of a flat track list
27
25
  /// (`children` authoring sugar, search results — ADR 0010).
28
26
  static func untitled(_ children: [Track]) -> Section {
29
- Section(title: nil, subtitle: nil, style: nil, path: nil, children: children)
27
+ Section(title: nil, style: nil, path: nil, children: children)
30
28
  }
31
29
  }
@@ -0,0 +1,27 @@
1
+ import UIKit
2
+
3
+ extension UIApplication {
4
+ /// The scene showing the app's own UI: the foreground-active one, else any
5
+ /// connected window scene. Nil when the app has no UI scene at all (running
6
+ /// headless for CarPlay), which callers must handle rather than assume.
7
+ ///
8
+ /// Replaces the deprecated `UIScreen.main` / `keyWindow` idioms — and exists
9
+ /// once because the walk was previously written out at each use, so the two
10
+ /// copies had already drifted apart.
11
+ var activeWindowScene: UIWindowScene? {
12
+ let scenes = connectedScenes.compactMap { $0 as? UIWindowScene }
13
+ return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
14
+ }
15
+ }
16
+
17
+ /// The scale to rasterise at for the app's own screen.
18
+ ///
19
+ /// `UITraitCollection.current` rather than the deprecated `UIScreen.main.scale`
20
+ /// (also unavailable on visionOS, which the podspec targets): on the main actor
21
+ /// it already reflects the environment being rendered for. CarPlay surfaces do
22
+ /// NOT use this — they rasterise at `carTraitCollection.displayScale`, the car
23
+ /// screen's own scale.
24
+ @MainActor
25
+ var appDisplayScale: CGFloat {
26
+ UITraitCollection.current.displayScale
27
+ }
@@ -92,8 +92,14 @@ final class HttpClient: @unchecked Sendable {
92
92
  httpRequest.headers?.forEach { name, value in
93
93
  urlRequest.setValue(value, forHTTPHeaderField: name)
94
94
  }
95
- // Set User-Agent (userAgent parameter takes precedence over headers)
96
- urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
95
+ // An explicit `User-Agent` in `headers` wins, matching the two sibling paths
96
+ // (MediaLoader.buildAssetOptions and the artwork path in
97
+ // BrowserManager+URLResolution). `userAgent` carries a non-nil default, so
98
+ // overwriting unconditionally would replace a consumer's header with
99
+ // "react-native-audio-browser" whenever they set only the header.
100
+ if httpRequest.headers?["User-Agent"] == nil {
101
+ urlRequest.setValue(httpRequest.userAgent, forHTTPHeaderField: "User-Agent")
102
+ }
97
103
 
98
104
  // Add body for non-GET requests
99
105
  switch httpRequest.method.uppercased() {
@@ -186,7 +186,8 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
186
186
  public var configuration: NativeBrowserConfiguration = .init(
187
187
  path: nil, request: nil, requestResolver: nil, browse: nil, browseResolver: nil, media: nil, artwork: nil, nowPlayingArtwork: nil, routes: nil,
188
188
  singleTrack: nil, handleTrackLoad: nil,
189
- androidControllerOfflineError: nil, carPlayLoadingTitle: nil, resolveAlbumPath: nil, formatNavigationError: nil,
189
+ androidControllerOfflineError: nil, viewMoreTitle: nil, carPlayLoadingTitle: nil, resolveAlbumPath: nil,
190
+ formatNavigationError: nil,
190
191
  ) {
191
192
  didSet {
192
193
  // Copied here, on the setter's thread: the Task below outlives this call,
@@ -384,6 +385,19 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
384
385
  }
385
386
 
386
387
  deinit {
388
+ // Block-based notification observers are retained by the center until an
389
+ // explicit removeObserver — observer death isn't detected — so without this
390
+ // every JS reload strands another token + closure. They capture `self`
391
+ // weakly, so a stranded one no-ops rather than misbehaving; what accumulates
392
+ // is the tokens and a wasted main-queue dispatch per interruption / route
393
+ // change / media-services reset. (`volumeObservation` needs no counterpart:
394
+ // NSKeyValueObservation invalidates itself when this instance releases it.)
395
+ for observer in [interruptionObserver, routeChangeObserver, mediaServicesResetObserver]
396
+ .compactMap(\.self)
397
+ {
398
+ NotificationCenter.default.removeObserver(observer)
399
+ }
400
+
387
401
  // Safety net: ensure the AVPlayer is stopped if this instance is deallocated.
388
402
  let player = self.player
389
403
  if Thread.isMainThread {
@@ -574,7 +588,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
574
588
  static let invalidateAllSentinel = "__rnab_invalidate_all__"
575
589
 
576
590
  public func notifyContentChanged(path: String) throws {
577
- onMainActor { browserManager.invalidateContentCache(path) }
591
+ // Throws on a contextual path (matching Android's `require`) — the caller
592
+ // must pass the container path, or the invalidation silently hits nothing.
593
+ try onMainActor { try browserManager.invalidateContentCache(path) }
578
594
 
579
595
  // Notify external controllers (CarPlay) that content changed
580
596
  externalContentChangedEmitter.emit(path)
@@ -754,6 +770,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
754
770
  guard let self else { return }
755
771
  self.applyAudioSessionCategory = applyCategory
756
772
 
773
+ // A re-setup REPLACES the player, so the outgoing one has to be torn down
774
+ // and un-published first. `TrackPlayer` has no deinit: dropping the
775
+ // reference alone would leave its AVPlayer and MPNowPlayingSession alive.
776
+ // And `playerAndConfiguredBrowser` holds the old player strongly — its
777
+ // `resolve` is a one-shot, so without the reset the gate would keep
778
+ // handing the dead player to the CarPlay controller and Siri intents,
779
+ // which both await it. No-op on first setup.
780
+ player?.destroy()
781
+ playerAndConfiguredBrowser.reset()
782
+
757
783
  // Create player with self as callbacks delegate
758
784
  player = TrackPlayer(callbacks: self)
759
785
 
@@ -853,6 +879,13 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
853
879
  if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
854
880
  applyPendingPlayerState()
855
881
 
882
+ // Push the resolved event cadence unconditionally. `updateOptions` applies
883
+ // the interval only when it *differs* from the stored one, but the store
884
+ // outlives the player — so on a re-setup with unchanged options the new
885
+ // player would come up with no progress timer at all.
886
+ player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
887
+ player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
888
+
856
889
  // Notify listeners that player is ready (e.g., CarPlay)
857
890
  playerAndConfiguredBrowser.check()
858
891
  }
@@ -917,8 +950,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
917
950
  onMainActor { playerOptions.toOptions() }
918
951
  }
919
952
 
953
+ /// Stored so a re-setup can re-apply it: unlike the other runtime options this
954
+ /// one has no home in `playerOptions`, and the player it was pushed to does not
955
+ /// survive `setupPlayer`.
956
+ @MainActor private var playbackIntervalEnabled = false
957
+
920
958
  public func setPlaybackIntervalEnabled(enabled: Bool) {
921
- onMainActor { player?.setPlaybackIntervalEnabled(enabled) }
959
+ onMainActor {
960
+ playbackIntervalEnabled = enabled
961
+ player?.setPlaybackIntervalEnabled(enabled)
962
+ }
922
963
  }
923
964
 
924
965
  // MARK: - Playback Control
@@ -1132,7 +1173,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1132
1173
  }
1133
1174
 
1134
1175
  public func setActiveTrackFavorited(favorited: Bool) throws {
1135
- onMainActor {
1176
+ try onMainActor {
1136
1177
  guard let track = player?.currentTrack, let identity = track.identity else { return }
1137
1178
  guard let index = player?.currentIndex, index >= 0 else { return }
1138
1179
  // Optimistically reflect in the authoritative favorite set so the
@@ -1144,7 +1185,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1144
1185
  // Hydration never overrides a caller-set `favorited`, so the queued
1145
1186
  // track must itself carry the latest explicit value (as Android's tag
1146
1187
  // swap and web's replaceTrack do).
1147
- player?.replace(index, updatedTrack)
1188
+ try player?.replace(index, updatedTrack)
1148
1189
  // The ONLY emit: a favorite toggle is an in-place mutation of the active
1149
1190
  // track, not a transition — onPlaybackActiveTrackChanged stays
1150
1191
  // transition-only, and the useActiveTrack / useQueue hooks subscribe to
@@ -1303,7 +1344,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1303
1344
  // Classify a stall by connectivity so the formatter can show "No internet connection" vs
1304
1345
  // "Reconnecting…" without a separate read. nil while not stalled.
1305
1346
  let stalled: StallReason? =
1306
- player.isStalled ? (networkMonitor.getOnline() ? .buffering : .offline) : nil
1347
+ player.isStalled ? (networkMonitor.isOnline ? .buffering : .offline) : nil
1307
1348
  player.nowPlayingUpdater.render(
1308
1349
  track: track,
1309
1350
  timedMetadata: latestTimedMetadata,
@@ -1319,7 +1360,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1319
1360
  // MARK: - Network
1320
1361
 
1321
1362
  public func getOnline() throws -> Bool {
1322
- networkMonitor.getOnline()
1363
+ // No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
1364
+ // cheap read on the JS thread instead of blocking on the main queue.
1365
+ networkMonitor.isOnline
1323
1366
  }
1324
1367
 
1325
1368
  // MARK: - System Volume
@@ -1499,11 +1542,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1499
1542
  public func openOutputPicker() throws {
1500
1543
  DispatchQueue.main.async { [weak self] in
1501
1544
  guard let self else { return }
1502
- guard let windowScene = UIApplication.shared.connectedScenes
1503
- .compactMap({ $0 as? UIWindowScene })
1504
- .first(where: { $0.activationState == .foregroundActive }),
1505
- let window = windowScene.windows.first(where: { $0.isKeyWindow }),
1506
- var topController = window.rootViewController
1545
+ guard let windowScene = UIApplication.shared.activeWindowScene,
1546
+ let window = windowScene.windows.first(where: { $0.isKeyWindow }),
1547
+ var topController = window.rootViewController
1507
1548
  else {
1508
1549
  self.logger.error("openOutputPicker: no active window scene / root view controller")
1509
1550
  return
@@ -1889,6 +1930,9 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1889
1930
  return
1890
1931
  }
1891
1932
  browser.logger.info("resume: cold — restoring persisted track")
1933
+ // Repeat / shuffle / speed come back before the queue is built — see
1934
+ // `restorePlaybackSettings(from:)` for why that ordering matters.
1935
+ player.restorePlaybackSettings(from: state)
1892
1936
  let startMs = (track.live == true) ? nil : state.positionMs
1893
1937
  // Match Android resume: re-expand the full queue from the track's contextual
1894
1938
  // path (parent container → siblings + selected index). Fall back to the single
@@ -112,9 +112,64 @@
112
112
  }
113
113
  }
114
114
 
115
+ enum ImageShape: Equatable {
116
+ case circular
117
+ case roundedRectangle
118
+
119
+ init?(fromString string: String) {
120
+ switch string {
121
+ case "circular": self = .circular
122
+ case "rounded-rectangle": self = .roundedRectangle
123
+ default: return nil
124
+ }
125
+ }
126
+ }
127
+
128
+ enum GridTile: Equatable {
129
+ case plain
130
+ case card
131
+ case condensed
132
+
133
+ init?(fromString string: String) {
134
+ switch string {
135
+ case "plain": self = .plain
136
+ case "card": self = .card
137
+ case "condensed": self = .condensed
138
+ default: return nil
139
+ }
140
+ }
141
+
142
+ // Mirrors the generated extension (nitrogen emits `stringValue` beside
143
+ // `init?(fromString:)` for every string-backed enum).
144
+ var stringValue: String {
145
+ switch self {
146
+ case .plain: "plain"
147
+ case .card: "card"
148
+ case .condensed: "condensed"
149
+ }
150
+ }
151
+ }
152
+
153
+ enum CardImage: Equatable {
154
+ case normal
155
+ case background
156
+
157
+ init?(fromString string: String) {
158
+ switch string {
159
+ case "normal": self = .normal
160
+ case "background": self = .background
161
+ default: return nil
162
+ }
163
+ }
164
+ }
165
+
115
166
  struct TrackStyle: Equatable {
116
167
  var display: StyleDisplay?
117
168
  var artworkRendering: ArtworkRendering?
169
+ var imageShape: ImageShape?
170
+ var accessorySymbol: String?
171
+ var cardTint: String?
172
+ var cardImage: CardImage?
118
173
  }
119
174
 
120
175
  // Member order matches the generated init (own properties before inherited
@@ -123,13 +178,17 @@
123
178
  // `swift test` passes while the app build breaks on argument order.
124
179
  struct SectionStyle: Equatable {
125
180
  var gridWrap: Bool?
181
+ var gridTile: GridTile?
126
182
  var display: StyleDisplay?
127
183
  var artworkRendering: ArtworkRendering?
184
+ var imageShape: ImageShape?
185
+ var accessorySymbol: String?
186
+ var cardTint: String?
187
+ var cardImage: CardImage?
128
188
  }
129
189
 
130
190
  struct Section: Equatable {
131
191
  var title: String?
132
- var subtitle: String?
133
192
  var style: SectionStyle?
134
193
  var path: String?
135
194
  var children: [Track]
@@ -126,14 +126,22 @@ public struct MediaIntentCriteria: Sendable {
126
126
 
127
127
  /// Whether `query` is effectively the host app's own name. Case- and
128
128
  /// diacritic-insensitive.
129
+ ///
130
+ /// Whole-word subset match in either direction. Siri may deliver only part of
131
+ /// the name ("Name" for «App Name»), so a partial match must count — but a
132
+ /// substring match swallowed any query appearing inside the app name, making
133
+ /// one-word stations unreachable by voice.
129
134
  private static func queryMatchesAppName(_ query: String, appName: String?) -> Bool {
130
135
  guard let appName else { return false }
131
- let normalize: (String) -> String = {
132
- $0.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil)
133
- .trimmingCharacters(in: .whitespacesAndNewlines)
136
+ let tokens: (String) -> Set<String> = {
137
+ Set(
138
+ $0.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: nil)
139
+ .split(whereSeparator: { !$0.isLetter && !$0.isNumber })
140
+ .map(String.init),
141
+ )
134
142
  }
135
- let q = normalize(query), a = normalize(appName)
143
+ let q = tokens(query), a = tokens(appName)
136
144
  guard !q.isEmpty, !a.isEmpty else { return false }
137
- return a.contains(q) || q.contains(a)
145
+ return q.isSubset(of: a) || a.isSubset(of: q)
138
146
  }
139
147
  }
@@ -38,19 +38,28 @@ final class MediaLoader {
38
38
  func resolveAndLoad(src: String, track: Track? = nil) {
39
39
  if let resolver = mediaUrlResolver {
40
40
  mediaResolverTask?.cancel()
41
- mediaResolverTask = Task {
42
- self.logger.debug("resolveAndLoad: starting resolution for \(src)")
41
+ // `[weak self]` like the sibling load tasks below: the resolver is
42
+ // consumer-supplied and `cancelAll()` cannot force an in-flight `await`
43
+ // to return, so a strong capture let a resolver that never settles pin
44
+ // this loader (which also retains the task) indefinitely.
45
+ // `Logger` is a value type, so capturing it directly keeps the logging
46
+ // off `self` — and `self` weak, so a consumer resolver that never settles
47
+ // can't pin the loader (`cancelAll()` can't force an in-flight `await` to
48
+ // return).
49
+ let logger = logger
50
+ mediaResolverTask = Task { [weak self] in
51
+ logger.debug("resolveAndLoad: starting resolution for \(src)")
43
52
 
44
53
  guard !Task.isCancelled else {
45
- self.logger.debug("resolveAndLoad: cancelled before start")
54
+ logger.debug("resolveAndLoad: cancelled before start")
46
55
  return
47
56
  }
48
57
 
49
- self.logger.debug("resolveAndLoad: calling resolver...")
58
+ logger.debug("resolveAndLoad: calling resolver...")
50
59
  let resolved = await resolver(src, track)
51
60
 
52
- guard !Task.isCancelled else {
53
- self.logger.debug("resolveAndLoad: cancelled after resolver returned")
61
+ guard !Task.isCancelled, let self else {
62
+ logger.debug("resolveAndLoad: cancelled after resolver returned")
54
63
  return
55
64
  }
56
65
 
@@ -96,7 +105,7 @@ final class MediaLoader {
96
105
  .availableMetadataFormats,
97
106
  ) else { return }
98
107
 
99
- guard !Task.isCancelled, pendingAsset == asset else { return }
108
+ guard isCurrent(pendingAsset) else { return }
100
109
 
101
110
  if !commonMetadata.isEmpty {
102
111
  delegate?.mediaLoaderDidReceiveCommonMetadata(commonMetadata)
@@ -104,19 +113,21 @@ final class MediaLoader {
104
113
 
105
114
  if !chapterLocales.isEmpty {
106
115
  for locale in chapterLocales {
107
- guard !Task.isCancelled else { return }
116
+ guard isCurrent(pendingAsset) else { return }
108
117
  if let chapters = try? await pendingAsset.loadChapterMetadataGroups(
109
118
  withTitleLocale: locale,
110
119
  containingItemsWithCommonKeys: [],
111
120
  ) {
121
+ guard isCurrent(pendingAsset) else { return }
112
122
  delegate?.mediaLoaderDidReceiveChapterMetadata(chapters)
113
123
  }
114
124
  }
115
125
  } else {
116
126
  let duration = await (try? pendingAsset.load(.duration)) ?? .zero
117
127
  for format in metadataFormats {
118
- guard !Task.isCancelled else { return }
128
+ guard isCurrent(pendingAsset) else { return }
119
129
  if let metadata = try? await pendingAsset.loadMetadata(for: format) {
130
+ guard isCurrent(pendingAsset) else { return }
120
131
  let timeRange = CMTimeRange(
121
132
  start: CMTime(seconds: 0, preferredTimescale: 1000),
122
133
  end: duration,
@@ -159,6 +170,19 @@ final class MediaLoader {
159
170
  }
160
171
  }
161
172
 
173
+ /// Whether `candidate` is still the load this loader cares about — checked
174
+ /// after EVERY await, not just once before a loop.
175
+ ///
176
+ /// Cancellation alone is not enough: it only sets a flag, and can't abort a
177
+ /// load already in flight inside AVFoundation (only `cancelLoading()` does,
178
+ /// and the track-change path deliberately doesn't call it — the old asset
179
+ /// still backs the playing item at that point). So a load that lands after
180
+ /// the track changed would otherwise attribute the old asset's metadata to
181
+ /// the newly loaded track.
182
+ private func isCurrent(_ candidate: AVURLAsset) -> Bool {
183
+ !Task.isCancelled && candidate == asset
184
+ }
185
+
162
186
  func cancelAll() {
163
187
  mediaResolverTask?.cancel()
164
188
  mediaResolverTask = nil