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
@@ -77,30 +77,50 @@ extension JsonArtwork {
77
77
  struct JsonStyle: Codable, Equatable {
78
78
  let display: String?
79
79
  let gridWrap: Bool?
80
+ let gridTile: String?
80
81
  let artworkRendering: String?
82
+ let imageShape: String?
83
+ let accessorySymbol: String?
84
+ let cardTint: String?
85
+ let cardImage: String?
81
86
 
82
87
  init(
83
88
  display: String? = nil,
84
89
  gridWrap: Bool? = nil,
90
+ gridTile: String? = nil,
85
91
  artworkRendering: String? = nil,
92
+ imageShape: String? = nil,
93
+ accessorySymbol: String? = nil,
94
+ cardTint: String? = nil,
95
+ cardImage: String? = nil,
86
96
  ) {
87
97
  self.display = display
88
98
  self.gridWrap = gridWrap
99
+ self.gridTile = gridTile
89
100
  self.artworkRendering = artworkRendering
101
+ self.imageShape = imageShape
102
+ self.accessorySymbol = accessorySymbol
103
+ self.cardTint = cardTint
104
+ self.cardImage = cardImage
90
105
  }
91
106
 
92
107
  init(from decoder: Decoder) throws {
93
108
  // A non-object value (the retired `style: 'grid'` string) decodes as an
94
109
  // empty block rather than failing the parent.
95
110
  guard let container = try? decoder.container(keyedBy: CodingKeys.self) else {
96
- display = nil
97
- gridWrap = nil
98
- artworkRendering = nil
111
+ self.init()
99
112
  return
100
113
  }
101
- display = (try? container.decodeIfPresent(String.self, forKey: .display)) ?? nil
102
- gridWrap = (try? container.decodeIfPresent(Bool.self, forKey: .gridWrap)) ?? nil
103
- artworkRendering = (try? container.decodeIfPresent(String.self, forKey: .artworkRendering)) ?? nil
114
+ self.init(
115
+ display: (try? container.decodeIfPresent(String.self, forKey: .display)) ?? nil,
116
+ gridWrap: (try? container.decodeIfPresent(Bool.self, forKey: .gridWrap)) ?? nil,
117
+ gridTile: (try? container.decodeIfPresent(String.self, forKey: .gridTile)) ?? nil,
118
+ artworkRendering: (try? container.decodeIfPresent(String.self, forKey: .artworkRendering)) ?? nil,
119
+ imageShape: (try? container.decodeIfPresent(String.self, forKey: .imageShape)) ?? nil,
120
+ accessorySymbol: (try? container.decodeIfPresent(String.self, forKey: .accessorySymbol)) ?? nil,
121
+ cardTint: (try? container.decodeIfPresent(String.self, forKey: .cardTint)) ?? nil,
122
+ cardImage: (try? container.decodeIfPresent(String.self, forKey: .cardImage)) ?? nil,
123
+ )
104
124
  }
105
125
  }
106
126
 
@@ -114,20 +134,47 @@ extension JsonStyle {
114
134
  func toTrackStyle() -> TrackStyle? {
115
135
  let display = display.flatMap { StyleDisplay(fromString: $0.lowercased()) }
116
136
  let artworkRendering = artworkRendering.flatMap { ArtworkRendering(fromString: $0.lowercased()) }
117
- guard display != nil || artworkRendering != nil else { return nil }
118
- return TrackStyle(display: display, artworkRendering: artworkRendering)
137
+ let imageShape = imageShape.flatMap { ImageShape(fromString: $0.lowercased()) }
138
+ // The 'none' sentinel is a legitimate value; only emptiness collapses.
139
+ let accessorySymbol = accessorySymbol?.isEmpty == false ? accessorySymbol : nil
140
+ let cardTint = cardTint?.isEmpty == false ? cardTint : nil
141
+ let cardImage = cardImage.flatMap { CardImage(fromString: $0.lowercased()) }
142
+ guard display != nil || artworkRendering != nil || imageShape != nil
143
+ || accessorySymbol != nil || cardTint != nil || cardImage != nil
144
+ else { return nil }
145
+ return TrackStyle(
146
+ display: display,
147
+ artworkRendering: artworkRendering,
148
+ imageShape: imageShape,
149
+ accessorySymbol: accessorySymbol,
150
+ cardTint: cardTint,
151
+ cardImage: cardImage,
152
+ )
119
153
  }
120
154
 
121
155
  func toSectionStyle() -> SectionStyle? {
122
156
  let display = display.flatMap { StyleDisplay(fromString: $0.lowercased()) }
157
+ let gridTile = gridTile.flatMap { GridTile(fromString: $0.lowercased()) }
123
158
  let artworkRendering = artworkRendering.flatMap { ArtworkRendering(fromString: $0.lowercased()) }
124
- guard display != nil || artworkRendering != nil || gridWrap != nil else { return nil }
159
+ let imageShape = imageShape.flatMap { ImageShape(fromString: $0.lowercased()) }
160
+ let accessorySymbol = accessorySymbol?.isEmpty == false ? accessorySymbol : nil
161
+ let cardTint = cardTint?.isEmpty == false ? cardTint : nil
162
+ let cardImage = cardImage.flatMap { CardImage(fromString: $0.lowercased()) }
163
+ guard display != nil || artworkRendering != nil || gridWrap != nil
164
+ || gridTile != nil || imageShape != nil || accessorySymbol != nil
165
+ || cardTint != nil || cardImage != nil
166
+ else { return nil }
125
167
  // Argument order is the generated init's (own properties before
126
168
  // inherited ones — Nitro flattens `extends` own-props-first).
127
169
  return SectionStyle(
128
170
  gridWrap: gridWrap,
171
+ gridTile: gridTile,
129
172
  display: display,
130
173
  artworkRendering: artworkRendering,
174
+ imageShape: imageShape,
175
+ accessorySymbol: accessorySymbol,
176
+ cardTint: cardTint,
177
+ cardImage: cardImage,
131
178
  )
132
179
  }
133
180
 
@@ -135,10 +182,18 @@ extension JsonStyle {
135
182
  /// An all-nil block snapshots as no block — the decoders collapse it to
136
183
  /// "no declaration" anyway, so never persist the distinction.
137
184
  init?(_ style: TrackStyle?) {
138
- guard let style, style.display != nil || style.artworkRendering != nil else { return nil }
185
+ guard let style,
186
+ style.display != nil || style.artworkRendering != nil
187
+ || style.imageShape != nil || style.accessorySymbol != nil
188
+ || style.cardTint != nil || style.cardImage != nil
189
+ else { return nil }
139
190
  self.init(
140
191
  display: Self.string(style.display),
141
192
  artworkRendering: Self.string(style.artworkRendering),
193
+ imageShape: Self.string(style.imageShape),
194
+ accessorySymbol: style.accessorySymbol,
195
+ cardTint: style.cardTint,
196
+ cardImage: Self.string(style.cardImage),
142
197
  )
143
198
  }
144
199
 
@@ -157,6 +212,22 @@ extension JsonStyle {
157
212
  case nil: nil
158
213
  }
159
214
  }
215
+
216
+ private static func string(_ shape: ImageShape?) -> String? {
217
+ switch shape {
218
+ case .circular: "circular"
219
+ case .roundedRectangle: "rounded-rectangle"
220
+ case nil: nil
221
+ }
222
+ }
223
+
224
+ private static func string(_ mode: CardImage?) -> String? {
225
+ switch mode {
226
+ case .normal: "normal"
227
+ case .background: "background"
228
+ case nil: nil
229
+ }
230
+ }
160
231
  }
161
232
 
162
233
  /// JSON model for a page section (ADR 0010). Legacy `groupTitle`/`imageRow`
@@ -164,20 +235,17 @@ extension JsonStyle {
164
235
  /// ignored dead weight.
165
236
  struct JsonSection: Codable {
166
237
  let title: String?
167
- let subtitle: String?
168
238
  let style: JsonStyle?
169
239
  let path: String?
170
240
  let children: [JsonTrack]
171
241
 
172
242
  init(
173
243
  title: String? = nil,
174
- subtitle: String? = nil,
175
244
  style: JsonStyle? = nil,
176
245
  path: String? = nil,
177
246
  children: [JsonTrack],
178
247
  ) {
179
248
  self.title = title
180
- self.subtitle = subtitle
181
249
  self.style = style
182
250
  self.path = path
183
251
  self.children = children
@@ -393,7 +461,6 @@ struct JsonTrack: Codable {
393
461
  func toNitro() -> Section {
394
462
  Section(
395
463
  title: title,
396
- subtitle: subtitle,
397
464
  style: style?.toSectionStyle(),
398
465
  path: path,
399
466
  children: children.map { $0.toNitro() },
@@ -479,7 +546,6 @@ struct JsonTrack: Codable {
479
546
  func toNitro() -> Section {
480
547
  Section(
481
548
  title: title,
482
- subtitle: subtitle,
483
549
  style: style?.toSectionStyle(),
484
550
  path: path,
485
551
  children: children.map { $0.toNitro() },
@@ -31,4 +31,34 @@ enum SectionPresentation: Equatable {
31
31
  self = .list
32
32
  }
33
33
  }
34
+
35
+ /// The accessory symbol a resolved block actually draws: nil when
36
+ /// undeclared, and nil for the `'none'` sentinel — the inheritance escape
37
+ /// that restores the derived accessory (a browsable row's chevron). Here,
38
+ /// not in the CarPlay factory, so the sentinel rule is testable off-device.
39
+ static func effectiveAccessorySymbol(_ style: TrackStyle?) -> String? {
40
+ guard let symbol = style?.accessorySymbol, symbol != "none" else { return nil }
41
+ return symbol
42
+ }
43
+
44
+ /// The element family a tile presentation renders on iOS 26+ — here, not
45
+ /// in the CarPlay factory, so the style→family mapping is testable
46
+ /// off-device. `gridTile` picks the family (plain by default); the plain
47
+ /// single-line shelf keeps row elements, the one plain element with a
48
+ /// subtitle slot. Pre-26 has no element API at all: the treatment drops
49
+ /// with the legacy image row (decorations drop before layout).
50
+ enum TileElementFamily: Equatable {
51
+ case rowElements
52
+ case imageGridElements
53
+ case cardElements
54
+ case condensedElements
55
+ }
56
+
57
+ static func tileFamily(for style: SectionStyle?, singleLine: Bool) -> TileElementFamily {
58
+ switch style?.gridTile {
59
+ case .card: .cardElements
60
+ case .condensed: .condensedElements
61
+ case .plain, nil: singleLine ? .rowElements : .imageGridElements
62
+ }
63
+ }
34
64
  }
@@ -28,8 +28,13 @@ enum StyleResolver {
28
28
  // ones — Nitro flattens `extends` own-props-first); the stub mirrors it.
29
29
  SectionStyle(
30
30
  gridWrap: section?.gridWrap ?? page?.gridWrap,
31
+ gridTile: section?.gridTile ?? page?.gridTile,
31
32
  display: section?.display ?? page?.display,
32
33
  artworkRendering: section?.artworkRendering ?? page?.artworkRendering,
34
+ imageShape: section?.imageShape ?? page?.imageShape,
35
+ accessorySymbol: section?.accessorySymbol ?? page?.accessorySymbol,
36
+ cardTint: section?.cardTint ?? page?.cardTint,
37
+ cardImage: section?.cardImage ?? page?.cardImage,
33
38
  )
34
39
  }
35
40
 
@@ -42,6 +47,12 @@ enum StyleResolver {
42
47
  // for this item's own layout.
43
48
  display: nil,
44
49
  artworkRendering: track?.artworkRendering ?? section?.artworkRendering,
50
+ imageShape: track?.imageShape ?? section?.imageShape,
51
+ // `'none'` resolves like any value — it is the renderer that treats it
52
+ // as "no accessory, derived behavior" (the inheritance escape).
53
+ accessorySymbol: track?.accessorySymbol ?? section?.accessorySymbol,
54
+ cardTint: track?.cardTint ?? section?.cardTint,
55
+ cardImage: track?.cardImage ?? section?.cardImage,
45
56
  )
46
57
  }
47
58
  }
@@ -1,15 +1,21 @@
1
1
  /// Compares the tab-list fields the CarPlay tab bar actually renders.
2
2
  enum TabBarEntries {
3
3
  /// Whether two tab lists produce identical tab-bar entries — title, path,
4
- /// and the artwork inputs of `applyTabBarEntry`. Used to suppress
4
+ /// the artwork inputs of `applyTabBarEntry`, and `disabled`. Used to suppress
5
5
  /// `tabsChanged` when a tabs re-query (e.g. from `invalidateAllContent()`)
6
6
  /// resolves to the same entries, so an unchanged result never churns the
7
7
  /// tab bar or resets its selection.
8
+ ///
9
+ /// `disabled` counts even though it renders nothing itself: a disabled tab is
10
+ /// filtered out of the tab bar entirely (`showTabBar`), so a tab flipping it
11
+ /// with otherwise-identical fields changes which tabs exist. Omitting it here
12
+ /// swallowed the emit, and the tab never appeared or disappeared.
8
13
  static func same(_ old: [Track]?, _ new: [Track]) -> Bool {
9
14
  guard let old, old.count == new.count else { return false }
10
15
  return zip(old, new).allSatisfy {
11
16
  $0.title == $1.title && $0.path == $1.path && $0.artwork == $1.artwork
12
17
  && $0.artworkSource?.uri == $1.artworkSource?.uri
18
+ && $0.disabled == $1.disabled
13
19
  }
14
20
  }
15
21
  }
@@ -26,9 +26,22 @@ public final class RNABCarPlayController: NSObject {
26
26
  private var trackSelector: TrackSelector?
27
27
 
28
28
  /// Track content subscriptions
29
- private var isStarted = false
30
29
  private var listenerRemovals: [() -> Void] = []
31
30
 
31
+ /// Identifies the current `start()` run; nil while stopped.
32
+ ///
33
+ /// A run needs an identity, not just a flag, because `start()` parks an
34
+ /// untracked Task on the uncancellable `playerAndConfiguredBrowser.wait()`,
35
+ /// which stays queued across a `reset()` and resumes every waiter. `restart()`
36
+ /// is `stop(); start()`, so a boolean is `true` again by the time the old
37
+ /// waiter resumes and both runs proceed — duplicate subscriptions and a leaked
38
+ /// `NowPlayingObserver` for the session. Comparing the captured run against
39
+ /// this answers "still running?" and "still me?" in one condition.
40
+ private var currentRun: UInt?
41
+ private var runCounter: UInt = 0
42
+
43
+ private var isStarted: Bool { currentRun != nil }
44
+
32
45
  /// Paths whose loading template has been pushed but not yet appeared. Guards a
33
46
  /// rapid double-tap from pushing the same destination twice (the first push is
34
47
  /// async, so `topTemplate` may not reflect it yet). Cleared when it appears.
@@ -97,6 +110,19 @@ public final class RNABCarPlayController: NSObject {
97
110
  }
98
111
  }
99
112
 
113
+ /// The tabs the tab bar actually shows. A disabled tab hides rather than
114
+ /// greys: `CPTabBarTemplate` has no disabled affordance, so per `Track`'s
115
+ /// rendering ladder an unavailable tab must not render as a normal-looking
116
+ /// control (matches Android Auto, which filters before its 4-tab cap).
117
+ ///
118
+ /// Named once because every site that reasons about the tab bar must agree on
119
+ /// it — `showTabBar` builds from this list, and `handleTabsChanged` compares
120
+ /// against it. Those two disagreeing is exactly what made the in-place
121
+ /// restamp dead code whenever any tab was disabled.
122
+ private static func visibleTabs(_ tabs: [Track]) -> [Track] {
123
+ tabs.filter { $0.disabled != true }
124
+ }
125
+
100
126
  /// Whether a list item identifies the currently active (loaded) track.
101
127
  ///
102
128
  /// Compares track identities (`id` when non-blank, else `src`): the active
@@ -140,7 +166,9 @@ public final class RNABCarPlayController: NSObject {
140
166
  @objc
141
167
  public func start() {
142
168
  guard !isStarted else { return }
143
- isStarted = true
169
+ runCounter &+= 1
170
+ currentRun = runCounter
171
+ let run = runCounter
144
172
 
145
173
  logger.info("Starting CarPlay controller")
146
174
 
@@ -167,7 +195,8 @@ public final class RNABCarPlayController: NSObject {
167
195
  // Wait for both browser and player to be ready
168
196
  Task { @MainActor in
169
197
  let (browser, _) = await playerAndConfiguredBrowser.wait()
170
- guard self.isStarted else { return }
198
+ // Answers "still running?" and "still this run?" at once.
199
+ guard self.currentRun == run else { return }
171
200
  self.logger.debug("AudioBrowser and player ready, setting up CarPlay")
172
201
  self.audioBrowser = browser
173
202
  self.isGated = browser.isGateActive
@@ -202,7 +231,7 @@ public final class RNABCarPlayController: NSObject {
202
231
  @objc
203
232
  public func stop() {
204
233
  guard isStarted else { return }
205
- isStarted = false
234
+ currentRun = nil
206
235
 
207
236
  logger.info("Stopping CarPlay controller")
208
237
 
@@ -465,11 +494,7 @@ public final class RNABCarPlayController: NSObject {
465
494
 
466
495
  @MainActor
467
496
  private func showTabBar(tabs allTabs: [Track]) async {
468
- // A disabled tab hides — CPTabBarTemplate has no gray affordance, so per
469
- // Track.disabled's rendering ladder an unavailable tab must not render as
470
- // a normal-looking control (matches Android Auto, which filters before
471
- // its 4-tab cap).
472
- let tabs = allTabs.filter { $0.disabled != true }
497
+ let tabs = Self.visibleTabs(allTabs)
473
498
 
474
499
  // Serialize concurrent tab-bar builds across BOTH the gated and non-gated
475
500
  // paths with one shared generation token: bump at entry, capture, and bail
@@ -484,26 +509,31 @@ public final class RNABCarPlayController: NSObject {
484
509
  gateBuildGeneration &+= 1
485
510
  let generation = gateBuildGeneration
486
511
 
487
- // A zero-tab CPTabBarTemplate renders blank, with no copy, so empty routes
488
- // to the .emptyContent error page like every other failure (formatted
489
- // through the app's formatNavigationError, ADR 0001). The guard lives at
490
- // this choke point — both the initial build and runtime tab changes pass
491
- // through here — and sits before the gate check, since a gated build from
492
- // zero tabs is equally blank.
493
- guard !tabs.isEmpty else {
494
- await showRootNavigationError(.code(.emptyContent))
495
- return
496
- }
497
-
498
512
  // While gated, each tab path is resolved per-request: a gated tab renders
499
513
  // the gate page with that request's chrome; an allowed tab shows real
500
514
  // content. This generalizes the old "all tabs show the gate" — a path the
501
515
  // resolver allows now shows real content.
516
+ //
517
+ // Ahead of the empty guard below, because a gated build from zero tabs is
518
+ // NOT blank: it paints a full-screen gate page as root (a gate raised
519
+ // before config loads is exactly that case). `showGatedTabBar` owns the
520
+ // empty-tabs decision and falls back to the same .emptyContent page when
521
+ // the resolver allows the root.
502
522
  if isGated {
503
523
  await showGatedTabBar(tabs: tabs, generation: generation)
504
524
  return
505
525
  }
506
526
 
527
+ // A zero-tab CPTabBarTemplate renders blank, with no copy, so empty routes
528
+ // to the .emptyContent error page like every other failure (formatted
529
+ // through the app's formatNavigationError, ADR 0001). The guard lives at
530
+ // this choke point — both the initial build and runtime tab changes pass
531
+ // through here.
532
+ guard !tabs.isEmpty else {
533
+ await showRootNavigationError(.code(.emptyContent))
534
+ return
535
+ }
536
+
507
537
  logger.info("Building tab bar with \(tabs.count) tabs")
508
538
 
509
539
  let maxTabs = CPTabBarTemplate.maximumTabCount
@@ -543,8 +573,7 @@ public final class RNABCarPlayController: NSObject {
543
573
 
544
574
  guard !tabs.isEmpty else {
545
575
  // Tabs unknown (config not loaded yet, or none) — resolve the root path
546
- // and show a single gate page (or, if allowed, fall back to a normal
547
- // build once tabs arrive).
576
+ // and show a single gate page.
548
577
  let outcome = await audioBrowser.gateDecision(
549
578
  for: NativeGateRequest(reason: .browse, path: nil, search: nil),
550
579
  )
@@ -554,6 +583,11 @@ public final class RNABCarPlayController: NSObject {
554
583
  interfaceController.safeSetRoot(
555
584
  makeGateTemplate(gate: outcome.chrome, tab: nil), animated: true,
556
585
  )
586
+ } else {
587
+ // The resolver allows the root, and there are no tabs to show — the
588
+ // same nothing-to-render case the ungated build routes to .emptyContent.
589
+ // Without this the loading spinner would stay as root forever.
590
+ await showRootNavigationError(.code(.emptyContent))
557
591
  }
558
592
  return
559
593
  }
@@ -835,6 +869,9 @@ public final class RNABCarPlayController: NSObject {
835
869
  /// filled by `templateDidAppear` → `loadContentIfNeeded` → `loadContent`, which
836
870
  /// runs once the template is on screen (the timing CarPlay needs: updates made
837
871
  /// right after a push are dropped). Backing out and re-tapping retries.
872
+ ///
873
+ /// One exception to pushing: a path that IS a tab selects that tab (see
874
+ /// `selectTab`).
838
875
  @MainActor
839
876
  private func navigateToPath(_ path: String, title: String) async {
840
877
  // The gate is resolved per-request: a gated path pushes the gate page
@@ -854,6 +891,9 @@ public final class RNABCarPlayController: NSObject {
854
891
  return
855
892
  }
856
893
  }
894
+ // A link to a tab's own path switches to that tab instead of pushing a
895
+ // second copy of it into the current tab's stack.
896
+ if selectTab(owning: path) { return }
857
897
  // Avoid pushing a duplicate if the top template already shows this path.
858
898
  if let top = interfaceController.topTemplate, getPath(from: top) == path {
859
899
  return
@@ -877,6 +917,40 @@ public final class RNABCarPlayController: NSObject {
877
917
  }
878
918
  }
879
919
 
920
+ /// Switches to the tab that owns `path`, if any — a "view all" header or row
921
+ /// pointing at a tab's own path belongs in the tab bar, not pushed on top of
922
+ /// whichever tab the user tapped from (which would strand them one level deep
923
+ /// in a list the bar already offers, with a Back button to a page they never
924
+ /// came from). Any pushed stack is popped first, so the tab opens at its root.
925
+ ///
926
+ /// Returns false — and the caller pushes as usual — when `path` is no tab,
927
+ /// when the root isn't the tab bar (the gate replaces it), or before iOS 17,
928
+ /// which has no tab-selection API.
929
+ @MainActor
930
+ private func selectTab(owning path: String) -> Bool {
931
+ guard #available(iOS 17.0, *),
932
+ let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
933
+ let tab = tabBar.templates.first(where: { getPath(from: $0) == path })
934
+ else { return false }
935
+ let isPushed = interfaceController.templates.count > 1
936
+ // Already showing: the destination is where the user asked to be, so this
937
+ // is handled — just don't animate a pop-and-select over it.
938
+ if tabBar.selectedTemplate === tab, !isPushed { return true }
939
+ logger.debug("Navigating to tab \(path) instead of pushing it")
940
+ guard isPushed else {
941
+ tabBar.select(tab)
942
+ return true
943
+ }
944
+ interfaceController.safePopToRoot(animated: true) { popped, _ in
945
+ // Selecting after a failed pop would leave the pushed stack over the
946
+ // newly selected tab; the caller has already returned, so a failure just
947
+ // leaves the user where they were.
948
+ guard popped else { return }
949
+ tabBar.select(tab)
950
+ }
951
+ return true
952
+ }
953
+
880
954
  /// Builds an empty list template for content that is still resolving.
881
955
  /// On iOS 18.4+ it shows the system spinner (`showsSpinnerWhileEmpty`). On older
882
956
  /// iOS it shows the app-localized `carPlayLoadingTitle` as the centered empty
@@ -951,16 +1025,18 @@ public final class RNABCarPlayController: NSObject {
951
1025
  @MainActor
952
1026
  private func handleTabsChanged(_ tabs: [Track]) {
953
1027
  logger.debug("Tabs changed: \(tabs.count) tabs")
1028
+ // Compare against the list showTabBar builds templates from, not the raw one.
1029
+ let visibleTabs = Self.visibleTabs(tabs)
954
1030
  // Same tabs by path while ungated → only the tab-bar entries changed
955
1031
  // (e.g. titles after a locale switch): restamp them in place. No rebuild,
956
1032
  // so the selected tab and any pushed navigation stack survive. (Gate
957
1033
  // templates carry no path, so a gated tab bar never matches here.)
958
1034
  if !isGated,
959
1035
  let tabBar = interfaceController.rootTemplate as? CPTabBarTemplate,
960
- tabBar.templates.count == min(tabs.count, CPTabBarTemplate.maximumTabCount),
961
- zip(tabBar.templates, tabs).allSatisfy({ getPath(from: $0) == $1.path })
1036
+ tabBar.templates.count == min(visibleTabs.count, CPTabBarTemplate.maximumTabCount),
1037
+ zip(tabBar.templates, visibleTabs).allSatisfy({ getPath(from: $0) == $1.path })
962
1038
  {
963
- for (template, tab) in zip(tabBar.templates, tabs) {
1039
+ for (template, tab) in zip(tabBar.templates, visibleTabs) {
964
1040
  applyTabBarEntry(to: template, for: tab)
965
1041
  }
966
1042
  pendingTabs = nil
@@ -1242,15 +1318,29 @@ public final class RNABCarPlayController: NSObject {
1242
1318
  logger.debug("Calling formatNavigationError callback...")
1243
1319
  // Call the JS callback and handle result
1244
1320
  let params = FormatNavigationErrorParams(error: error, defaultFormatted: defaultFormatted, path: path)
1321
+ // The result callbacks are @Sendable and hop back explicitly: Nitro resolves
1322
+ // its promises synchronously on the JS thread, so a plain closure would
1323
+ // inherit this type's MainActor isolation and trap on entry under Swift 6's
1324
+ // dynamic isolation check — and would otherwise drive UIKit off the main
1325
+ // thread. Same pattern as HybridAudioBrowser.handleNavigationError and
1326
+ // NowPlayingUpdater.render.
1245
1327
  formatter(params)
1246
- .then { [weak self] customDisplay in
1247
- self?.logger.debug("formatNavigationError returned: \(String(describing: customDisplay))")
1248
- self?.presentErrorActionSheet(customDisplay: customDisplay ?? defaultFormatted)
1328
+ .then { @Sendable [weak self] customDisplay in
1329
+ let description = String(describing: customDisplay)
1330
+ Task { @MainActor in
1331
+ guard let self else { return }
1332
+ self.logger.debug("formatNavigationError returned: \(description)")
1333
+ self.presentErrorActionSheet(customDisplay: customDisplay ?? defaultFormatted)
1334
+ }
1249
1335
  }
1250
- .catch { [weak self] callbackError in
1251
- self?.logger.error("formatNavigationError failed: \(callbackError)")
1252
- // On error, fall back to defaults
1253
- self?.presentErrorActionSheet(customDisplay: defaultFormatted)
1336
+ .catch { @Sendable [weak self] callbackError in
1337
+ let message = callbackError.localizedDescription
1338
+ Task { @MainActor in
1339
+ guard let self else { return }
1340
+ self.logger.error("formatNavigationError failed: \(message)")
1341
+ // On error, fall back to defaults
1342
+ self.presentErrorActionSheet(customDisplay: defaultFormatted)
1343
+ }
1254
1344
  }
1255
1345
  } else {
1256
1346
  presentErrorActionSheet(customDisplay: defaultFormatted)
@@ -1291,9 +1381,22 @@ public final class RNABCarPlayController: NSObject {
1291
1381
  /// view formatted via the app's `formatNavigationError` (path "/"), like every
1292
1382
  /// other browse failure. The empty view renders reliably here because it's the
1293
1383
  /// template's initial state at set-root time.
1384
+ ///
1385
+ /// Formatting awaits the app's `formatNavigationError` callback, so a tab-bar
1386
+ /// build can start and paint during the hop — this error root must not
1387
+ /// clobber it. Captured here rather than passed in: the `showTabBar` /
1388
+ /// `showGatedTabBar` callers hold the newest generation when they call, so
1389
+ /// capturing is equivalent for them and covers the others for free.
1390
+ ///
1391
+ /// Not airtight for `buildInitialInterface`'s two callers: that function
1392
+ /// doesn't participate in `gateBuildGeneration` and suspends at `queryTabs()`,
1393
+ /// so a build that superseded it *before* this was entered isn't caught.
1394
+ /// Closing that needs a generation captured at `buildInitialInterface` entry.
1294
1395
  @MainActor
1295
1396
  private func showRootNavigationError(_ navError: NavigationError) async {
1397
+ let generation = gateBuildGeneration
1296
1398
  let formatted = await formattedNavigationError(navError, path: "/")
1399
+ guard gateBuildGeneration == generation else { return }
1297
1400
  let template = CPListTemplate(title: nil, sections: [])
1298
1401
  template.emptyViewTitleVariants = [formatted.title]
1299
1402
  template.emptyViewSubtitleVariants = formatted.message.flatMap { $0.isEmpty ? nil : [$0] } ?? []
@@ -1382,12 +1485,13 @@ extension UIImage {
1382
1485
  UIGraphicsBeginImageContextWithOptions(targetSize, false, scale)
1383
1486
  defer { UIGraphicsEndImageContext() }
1384
1487
 
1385
- // Scale to fit while maintaining aspect ratio
1488
+ // Scale to fit while maintaining aspect ratio. Named `fitScale` so it does
1489
+ // not shadow the `scale` parameter (the display scale) it sits next to.
1386
1490
  let widthRatio = targetSize.width / size.width
1387
1491
  let heightRatio = targetSize.height / size.height
1388
- let scale = min(widthRatio, heightRatio)
1492
+ let fitScale = min(widthRatio, heightRatio)
1389
1493
 
1390
- let scaledSize = CGSize(width: size.width * scale, height: size.height * scale)
1494
+ let scaledSize = CGSize(width: size.width * fitScale, height: size.height * fitScale)
1391
1495
  let origin = CGPoint(
1392
1496
  x: (targetSize.width - scaledSize.width) / 2,
1393
1497
  y: (targetSize.height - scaledSize.height) / 2,