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

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 (156) 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 +5 -1
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +8 -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 -9
  7. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
  8. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
  9. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  10. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
  11. package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
  12. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  13. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
  14. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -1
  15. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +49 -3
  16. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
  17. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  18. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
  19. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
  20. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
  21. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
  22. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  23. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +8 -8
  24. package/ios/Browser/BrowserManager.swift +106 -45
  25. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  26. package/ios/Browser/JsonModels.swift +81 -10
  27. package/ios/Browser/SectionPresentation.swift +30 -0
  28. package/ios/Browser/StyleResolver.swift +11 -0
  29. package/ios/Browser/TabBarEntries.swift +7 -1
  30. package/ios/CarPlay/CarPlayController.swift +139 -35
  31. package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
  32. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  33. package/ios/Http/HttpClient.swift +8 -2
  34. package/ios/HybridAudioBrowser.swift +54 -11
  35. package/ios/Model/NitroTypeStubs.swift +60 -0
  36. package/ios/Player/MediaIntentCriteria.swift +13 -5
  37. package/ios/Player/MediaLoader.swift +33 -9
  38. package/ios/Player/NowPlayingUpdater.swift +53 -5
  39. package/ios/Player/PlaybackCoordinator.swift +11 -4
  40. package/ios/Player/PlaybackTimer.swift +8 -2
  41. package/ios/Player/QueueManager.swift +36 -10
  42. package/ios/Player/ShuffleOrder.swift +23 -0
  43. package/ios/Player/SleepTimerHandling.swift +13 -0
  44. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  45. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  46. package/ios/Tests/JsonModelsTests.swift +76 -1
  47. package/ios/Tests/QueueManagerTests.swift +72 -6
  48. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  49. package/ios/Tests/SectionPresentationTests.swift +29 -0
  50. package/ios/Tests/StyleResolverTests.swift +60 -0
  51. package/ios/Tests/Support/TestTypes.swift +7 -0
  52. package/ios/TrackPlayer.swift +57 -34
  53. package/ios/Util/NetworkMonitor.swift +43 -26
  54. package/ios/Util/SFSymbolRenderer.swift +24 -2
  55. package/ios/Util/SVGProcessor.swift +9 -2
  56. package/lib/commonjs/features/playback/progress.js +2 -1
  57. package/lib/commonjs/features/playback/progress.js.map +1 -1
  58. package/lib/commonjs/features/playback/rate.js +8 -1
  59. package/lib/commonjs/features/playback/rate.js.map +1 -1
  60. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  61. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  62. package/lib/module/features/playback/progress.js +2 -1
  63. package/lib/module/features/playback/progress.js.map +1 -1
  64. package/lib/module/features/playback/rate.js +8 -1
  65. package/lib/module/features/playback/rate.js.map +1 -1
  66. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  67. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  68. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  69. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  70. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  71. package/lib/typescript/src/types/browser-nodes.d.ts +104 -2
  72. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  73. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
  74. package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
  75. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  76. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
  77. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
  78. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
  79. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
  80. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
  81. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
  82. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
  83. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
  84. 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
  85. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
  86. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
  87. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
  88. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
  89. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  90. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +12 -0
  91. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  92. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
  93. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +6 -0
  94. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
  95. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
  96. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
  97. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
  98. package/nitrogen/generated/android/c++/JSection.hpp +6 -0
  99. package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
  100. package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
  101. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
  102. package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
  103. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
  104. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  105. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  106. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  107. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
  108. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
  109. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +54 -0
  110. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
  111. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
  112. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  113. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  114. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  115. package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
  116. package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
  117. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  118. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  119. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  120. package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
  121. package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
  122. package/package.json +7 -2
  123. package/src/features/playback/progress.ts +2 -1
  124. package/src/features/playback/rate.ts +8 -1
  125. package/src/style-resolution.test.ts +72 -1
  126. package/src/types/browser-nodes.ts +112 -2
  127. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  128. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  129. package/website/guide/android-auto.md +114 -0
  130. package/website/guide/android-certificates.md +409 -0
  131. package/website/guide/artwork.md +255 -0
  132. package/website/guide/audio-output.md +135 -0
  133. package/website/guide/automotive.md +86 -0
  134. package/website/guide/basic-usage.md +151 -0
  135. package/website/guide/battery.md +115 -0
  136. package/website/guide/browser.md +581 -0
  137. package/website/guide/carplay.md +620 -0
  138. package/website/guide/configuration.md +173 -0
  139. package/website/guide/equalizer.md +231 -0
  140. package/website/guide/errors.md +310 -0
  141. package/website/guide/favorites.md +154 -0
  142. package/website/guide/gate.md +143 -0
  143. package/website/guide/getting-started.md +46 -0
  144. package/website/guide/hooks.md +238 -0
  145. package/website/guide/live-streams.md +125 -0
  146. package/website/guide/metadata.md +239 -0
  147. package/website/guide/migrating-from-track-player.md +244 -0
  148. package/website/guide/native-callback-fetch.md +127 -0
  149. package/website/guide/network.md +86 -0
  150. package/website/guide/now-playing.md +175 -0
  151. package/website/guide/playback.md +328 -0
  152. package/website/guide/queue.md +300 -0
  153. package/website/guide/remote-controls.md +256 -0
  154. package/website/guide/search.md +255 -0
  155. package/website/guide/sleep-timer.md +268 -0
  156. 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 }
@@ -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() {
@@ -384,6 +384,19 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
384
384
  }
385
385
 
386
386
  deinit {
387
+ // Block-based notification observers are retained by the center until an
388
+ // explicit removeObserver — observer death isn't detected — so without this
389
+ // every JS reload strands another token + closure. They capture `self`
390
+ // weakly, so a stranded one no-ops rather than misbehaving; what accumulates
391
+ // is the tokens and a wasted main-queue dispatch per interruption / route
392
+ // change / media-services reset. (`volumeObservation` needs no counterpart:
393
+ // NSKeyValueObservation invalidates itself when this instance releases it.)
394
+ for observer in [interruptionObserver, routeChangeObserver, mediaServicesResetObserver]
395
+ .compactMap(\.self)
396
+ {
397
+ NotificationCenter.default.removeObserver(observer)
398
+ }
399
+
387
400
  // Safety net: ensure the AVPlayer is stopped if this instance is deallocated.
388
401
  let player = self.player
389
402
  if Thread.isMainThread {
@@ -574,7 +587,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
574
587
  static let invalidateAllSentinel = "__rnab_invalidate_all__"
575
588
 
576
589
  public func notifyContentChanged(path: String) throws {
577
- onMainActor { browserManager.invalidateContentCache(path) }
590
+ // Throws on a contextual path (matching Android's `require`) — the caller
591
+ // must pass the container path, or the invalidation silently hits nothing.
592
+ try onMainActor { try browserManager.invalidateContentCache(path) }
578
593
 
579
594
  // Notify external controllers (CarPlay) that content changed
580
595
  externalContentChangedEmitter.emit(path)
@@ -754,6 +769,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
754
769
  guard let self else { return }
755
770
  self.applyAudioSessionCategory = applyCategory
756
771
 
772
+ // A re-setup REPLACES the player, so the outgoing one has to be torn down
773
+ // and un-published first. `TrackPlayer` has no deinit: dropping the
774
+ // reference alone would leave its AVPlayer and MPNowPlayingSession alive.
775
+ // And `playerAndConfiguredBrowser` holds the old player strongly — its
776
+ // `resolve` is a one-shot, so without the reset the gate would keep
777
+ // handing the dead player to the CarPlay controller and Siri intents,
778
+ // which both await it. No-op on first setup.
779
+ player?.destroy()
780
+ playerAndConfiguredBrowser.reset()
781
+
757
782
  // Create player with self as callbacks delegate
758
783
  player = TrackPlayer(callbacks: self)
759
784
 
@@ -853,6 +878,13 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
853
878
  if let intent = options.playWhenReady { pendingPlayWhenReady = intent }
854
879
  applyPendingPlayerState()
855
880
 
881
+ // Push the resolved event cadence unconditionally. `updateOptions` applies
882
+ // the interval only when it *differs* from the stored one, but the store
883
+ // outlives the player — so on a re-setup with unchanged options the new
884
+ // player would come up with no progress timer at all.
885
+ player?.setProgressUpdateInterval(playerOptions.progressUpdateEventInterval)
886
+ player?.setPlaybackIntervalEnabled(playbackIntervalEnabled)
887
+
856
888
  // Notify listeners that player is ready (e.g., CarPlay)
857
889
  playerAndConfiguredBrowser.check()
858
890
  }
@@ -917,8 +949,16 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
917
949
  onMainActor { playerOptions.toOptions() }
918
950
  }
919
951
 
952
+ /// Stored so a re-setup can re-apply it: unlike the other runtime options this
953
+ /// one has no home in `playerOptions`, and the player it was pushed to does not
954
+ /// survive `setupPlayer`.
955
+ @MainActor private var playbackIntervalEnabled = false
956
+
920
957
  public func setPlaybackIntervalEnabled(enabled: Bool) {
921
- onMainActor { player?.setPlaybackIntervalEnabled(enabled) }
958
+ onMainActor {
959
+ playbackIntervalEnabled = enabled
960
+ player?.setPlaybackIntervalEnabled(enabled)
961
+ }
922
962
  }
923
963
 
924
964
  // MARK: - Playback Control
@@ -1132,7 +1172,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1132
1172
  }
1133
1173
 
1134
1174
  public func setActiveTrackFavorited(favorited: Bool) throws {
1135
- onMainActor {
1175
+ try onMainActor {
1136
1176
  guard let track = player?.currentTrack, let identity = track.identity else { return }
1137
1177
  guard let index = player?.currentIndex, index >= 0 else { return }
1138
1178
  // Optimistically reflect in the authoritative favorite set so the
@@ -1144,7 +1184,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1144
1184
  // Hydration never overrides a caller-set `favorited`, so the queued
1145
1185
  // track must itself carry the latest explicit value (as Android's tag
1146
1186
  // swap and web's replaceTrack do).
1147
- player?.replace(index, updatedTrack)
1187
+ try player?.replace(index, updatedTrack)
1148
1188
  // The ONLY emit: a favorite toggle is an in-place mutation of the active
1149
1189
  // track, not a transition — onPlaybackActiveTrackChanged stays
1150
1190
  // transition-only, and the useActiveTrack / useQueue hooks subscribe to
@@ -1303,7 +1343,7 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1303
1343
  // Classify a stall by connectivity so the formatter can show "No internet connection" vs
1304
1344
  // "Reconnecting…" without a separate read. nil while not stalled.
1305
1345
  let stalled: StallReason? =
1306
- player.isStalled ? (networkMonitor.getOnline() ? .buffering : .offline) : nil
1346
+ player.isStalled ? (networkMonitor.isOnline ? .buffering : .offline) : nil
1307
1347
  player.nowPlayingUpdater.render(
1308
1348
  track: track,
1309
1349
  timedMetadata: latestTimedMetadata,
@@ -1319,7 +1359,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1319
1359
  // MARK: - Network
1320
1360
 
1321
1361
  public func getOnline() throws -> Bool {
1322
- networkMonitor.getOnline()
1362
+ // No `onMainActor` hop: `isOnline` is lock-protected, so this stays a
1363
+ // cheap read on the JS thread instead of blocking on the main queue.
1364
+ networkMonitor.isOnline
1323
1365
  }
1324
1366
 
1325
1367
  // MARK: - System Volume
@@ -1499,11 +1541,9 @@ public class HybridAudioBrowser: HybridAudioBrowserSpec, @unchecked Sendable {
1499
1541
  public func openOutputPicker() throws {
1500
1542
  DispatchQueue.main.async { [weak self] in
1501
1543
  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
1544
+ guard let windowScene = UIApplication.shared.activeWindowScene,
1545
+ let window = windowScene.windows.first(where: { $0.isKeyWindow }),
1546
+ var topController = window.rootViewController
1507
1547
  else {
1508
1548
  self.logger.error("openOutputPicker: no active window scene / root view controller")
1509
1549
  return
@@ -1889,6 +1929,9 @@ extension HybridAudioBrowser: TrackPlayerCallbacks {
1889
1929
  return
1890
1930
  }
1891
1931
  browser.logger.info("resume: cold — restoring persisted track")
1932
+ // Repeat / shuffle / speed come back before the queue is built — see
1933
+ // `restorePlaybackSettings(from:)` for why that ordering matters.
1934
+ player.restorePlaybackSettings(from: state)
1892
1935
  let startMs = (track.live == true) ? nil : state.positionMs
1893
1936
  // Match Android resume: re-expand the full queue from the track's contextual
1894
1937
  // 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,8 +178,13 @@
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 {
@@ -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
@@ -30,17 +30,51 @@ final class NowPlayingUpdater {
30
30
  let album: String?
31
31
  }
32
32
 
33
+ /// Dedupe baseline for `applyFields`. Only meaningful while it describes the
34
+ /// controller's current info dict, so the two are cleared together in
35
+ /// `clearNowPlaying()` — never separately.
33
36
  private var lastPublished: Published?
34
37
 
38
+ /// Empties the now-playing surface and the dedupe baseline that describes it.
39
+ /// One call, because clearing the dict without the baseline makes the next
40
+ /// publish of the SAME track dedupe against a dict that no longer holds it —
41
+ /// artwork lands (never deduped) but the text doesn't, and `onChanged`
42
+ /// never fires.
43
+ func clearNowPlaying() {
44
+ nowPlayingInfoController.clear()
45
+ lastPublished = nil
46
+ formattedTrackId = nil
47
+ }
48
+
35
49
  /// Bumped on every `render`; an async formatter result applies only if its render is still the
36
50
  /// latest (latest-render-wins — drops a result whose track skipped or whose state moved on while
37
51
  /// the formatter was in flight).
38
52
  private var renderGeneration: UInt = 0
39
53
 
54
+ /// The track whose *formatted* line is currently on the surface. While this
55
+ /// matches the rendering track, `render` skips its interim default write — see
56
+ /// the comment at that site. Cleared whenever the surface stops showing a
57
+ /// formatter result (a flash overwrites it, `clearNowPlaying` empties it).
58
+ private var formattedTrackId: String?
59
+
40
60
  init(nowPlayingInfoController: NowPlayingInfoController) {
41
61
  self.nowPlayingInfoController = nowPlayingInfoController
42
62
  }
43
63
 
64
+ /// Pixel width to request now-playing artwork at: the active scene's screen
65
+ /// width in pixels, capped at `maxArtworkPixelWidth`. With no foreground
66
+ /// scene (headless CarPlay) the cap stands in — every current device exceeds
67
+ /// it anyway, and this only sizes a CDN request.
68
+ private static var artworkPixelWidth: CGFloat {
69
+ guard let screen = UIApplication.shared.activeWindowScene?.screen else {
70
+ return maxArtworkPixelWidth
71
+ }
72
+ return min(screen.bounds.width * screen.scale, maxArtworkPixelWidth)
73
+ }
74
+
75
+ /// Mirrored by the Kotlin twin's `artworkSizePx`.
76
+ private static let maxArtworkPixelWidth: CGFloat = 1200
77
+
44
78
  /// Publishes the static, per-track fields + artwork on a track change. The title/artist/album
45
79
  /// lines are owned by `render` (routed through `applyFields` here so it dedupes against later
46
80
  /// renders).
@@ -75,6 +109,8 @@ final class NowPlayingUpdater {
75
109
  let defaultSecondary = override?.artist ?? track.artist
76
110
  let defaultAlbum = override?.album ?? track.album
77
111
 
112
+ let trackId = track.identity ?? track.path
113
+
78
114
  if let flash {
79
115
  applyFields(
80
116
  track: track,
@@ -82,10 +118,22 @@ final class NowPlayingUpdater {
82
118
  artist: flash.artist ?? defaultSecondary,
83
119
  album: flash.album ?? defaultAlbum,
84
120
  )
121
+ // The flash replaced the formatter's line, so the surface no longer shows
122
+ // one: the render that follows the flash reverting must publish the default
123
+ // immediately rather than sit on stale flash text awaiting the formatter.
124
+ formattedTrackId = nil
85
125
  return
86
126
  }
87
127
 
88
- applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
128
+ // Publish the raw default only while no formatter result is on screen for this
129
+ // track. `render` runs on EVERY playback transition (state change, playing/
130
+ // buffering flip, timed-metadata tick, connectivity change); writing the
131
+ // default each time would flash the track title over the formatter's line
132
+ // until the async result lands, since the two differ by construction on a live
133
+ // stream (station name vs. song line).
134
+ if formatter == nil || formattedTrackId != trackId {
135
+ applyFields(track: track, title: defaultTitle, artist: defaultSecondary, album: defaultAlbum)
136
+ }
89
137
 
90
138
  guard let formatter else { return }
91
139
  let params = FormatNowPlayingParams(
@@ -111,6 +159,9 @@ final class NowPlayingUpdater {
111
159
  artist: formatted.artist ?? defaultSecondary,
112
160
  album: formatted.album ?? defaultAlbum,
113
161
  )
162
+ // A formatter result now owns the surface for this track, so later
163
+ // renders skip the interim default write.
164
+ self.formattedTrackId = trackId
114
165
  }
115
166
  }
116
167
  .catch { @Sendable [weak self] error in
@@ -152,10 +203,7 @@ final class NowPlayingUpdater {
152
203
  let artworkUrl = track.artworkSource?.uri ?? track.artwork?.url
153
204
  logger.debug("loadArtwork: \(track.title), artworkUrl: \(artworkUrl ?? "nil")")
154
205
 
155
- // Now Playing artwork: use screen width in pixels, capped at 1200px
156
- let screenScale = UIScreen.main.scale
157
- let screenWidth = UIScreen.main.bounds.width * screenScale
158
- let artworkSize = min(screenWidth, 1200)
206
+ let artworkSize = Self.artworkPixelWidth
159
207
  let nowPlayingSize = ImageContext(width: artworkSize, height: artworkSize)
160
208
 
161
209
  artworkLoadTask?.cancel()