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
package/README.md CHANGED
@@ -174,6 +174,16 @@ Full guides and the complete [API Reference](https://audiobrowser.dev/api/) live
174
174
 
175
175
  The example app in [`apps/example-native`](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native) browses archive.org's audio collection — in-app, on CarPlay, and on Android Auto — with search, favorites, and the gate.
176
176
 
177
+ ### For coding agents
178
+
179
+ Every page is also served as raw Markdown — append `.md` to any URL, e.g.
180
+ [`audiobrowser.dev/guide/queue.md`](https://audiobrowser.dev/guide/queue.md).
181
+
182
+ - [`/llms.txt`](https://audiobrowser.dev/llms.txt) — an index of every page with a one-line summary, to pick what to read.
183
+ - [`/llms-full.txt`](https://audiobrowser.dev/llms-full.txt) — every guide in one file.
184
+
185
+ The same guide Markdown ships in the package, so it can be read straight off disk without a network fetch: `node_modules/react-native-audio-browser/website/guide/`.
186
+
177
187
  ## Support
178
188
 
179
189
  Questions or bugs? [Open an issue](https://github.com/radio-garden/react-native-audio-browser/issues). Release notes live in the [changelog](CHANGELOG.md).
@@ -158,6 +158,13 @@ repositories {
158
158
  google()
159
159
  }
160
160
 
161
+ // Mockito's inline mock maker needs a Byte Buddy agent in the running JVM. Left to itself it
162
+ // installs one through the JVM attach mechanism, which binds a unix socket under $TMPDIR —
163
+ // denied inside sandboxed shells, and disabled by default on JDK 24+. Resolving the agent as its
164
+ // own configuration lets the test JVM pre-load it with -javaagent, so no attach is needed.
165
+ configurations {
166
+ mockitoAgent
167
+ }
161
168
 
162
169
  dependencies {
163
170
  // For < 0.71, this will be from the local maven repo
@@ -210,14 +217,26 @@ dependencies {
210
217
  implementation("io.coil-kt.coil3:coil-network-okhttp:3.3.0")
211
218
 
212
219
  // Testing dependencies
220
+ def mockitoVersion = "5.8.0"
213
221
  testImplementation 'junit:junit:4.13.2'
214
- testImplementation 'org.mockito:mockito-core:5.8.0'
222
+ testImplementation "org.mockito:mockito-core:${mockitoVersion}"
223
+ // Resolved transitively so the agent jar always matches the Byte Buddy that Mockito uses.
224
+ mockitoAgent "org.mockito:mockito-core:${mockitoVersion}"
215
225
  testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3'
216
226
  testImplementation 'org.robolectric:robolectric:4.11.1'
217
227
 
218
228
  api 'com.jakewharton.timber:timber:5.0.1'
219
229
  }
220
230
 
231
+ tasks.withType(Test).configureEach {
232
+ def agentJar = configurations.mockitoAgent.find { it.name.startsWith("byte-buddy-agent") }
233
+ if (agentJar == null) {
234
+ throw new GradleException("No byte-buddy-agent jar in the mockitoAgent configuration; " +
235
+ "Mockito would fall back to runtime self-attach. Check the mockito-core coordinates.")
236
+ }
237
+ jvmArgs "-javaagent:${agentJar}"
238
+ }
239
+
221
240
  if (isNewArchitectureEnabled()) {
222
241
  react {
223
242
  jsRootDir = file("../src/")
@@ -20,6 +20,7 @@ import com.audiobrowser.browser.CallbackException
20
20
  import com.audiobrowser.browser.ContentNotFoundException
21
21
  import com.audiobrowser.browser.HttpStatusException
22
22
  import com.audiobrowser.browser.NetworkException
23
+ import com.audiobrowser.browser.flattenedChildren
23
24
  import com.audiobrowser.browser.handleTrackLoad
24
25
  import com.audiobrowser.browser.resolveMediaUrl
25
26
  import com.audiobrowser.extension.NumberExt.Companion.toSeconds
@@ -202,6 +203,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
202
203
  singleTrack = null,
203
204
  handleTrackLoad = null,
204
205
  androidControllerOfflineError = null,
206
+ viewMoreTitle = null,
205
207
  carPlayLoadingTitle = null,
206
208
  resolveAlbumPath = null,
207
209
  formatNavigationError = null,
@@ -368,6 +370,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
368
370
  routes = _configuration.routes,
369
371
  singleTrack = _configuration.singleTrack ?: false,
370
372
  androidControllerOfflineError = _configuration.androidControllerOfflineError ?: true,
373
+ viewMoreTitle = _configuration.viewMoreTitle,
371
374
  )
372
375
  }
373
376
 
@@ -757,7 +760,10 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
757
760
  return Promise.async(mainScope) {
758
761
  Timber.d("Searching for: $query")
759
762
  val searchResults = browserManager.search(query)
760
- searchResults.children ?: emptyArray()
763
+ // Flattened, not `children`: a resolved page carries its rows in
764
+ // `sections` (ADR 0010) and search builds exactly one untitled section,
765
+ // so `children` is always null here and this returned nothing at all.
766
+ searchResults.flattenedChildren?.toTypedArray() ?: emptyArray()
761
767
  }
762
768
  }
763
769
 
@@ -1567,6 +1573,7 @@ private fun NativeBrowserConfiguration.strippingJSCallbacks() =
1567
1573
  requestResolver = null,
1568
1574
  browseResolver = null,
1569
1575
  handleTrackLoad = null,
1576
+ viewMoreTitle = null,
1570
1577
  resolveAlbumPath = null,
1571
1578
  formatNavigationError = null,
1572
1579
  request = request?.strippingJSCallbacks(),
@@ -24,6 +24,7 @@ import com.margelo.nitro.audiobrowser.Section
24
24
  import com.margelo.nitro.audiobrowser.StyleDisplay
25
25
  import com.margelo.nitro.audiobrowser.Track
26
26
  import com.margelo.nitro.audiobrowser.TransformableRequestConfig
27
+ import com.margelo.nitro.core.Promise
27
28
  import kotlinx.coroutines.Dispatchers
28
29
  import kotlinx.coroutines.async
29
30
  import kotlinx.coroutines.awaitAll
@@ -133,6 +134,12 @@ class BrowserManager {
133
134
  private var resolvedRequestLayer: TransformableRequestConfig? = null
134
135
  private var resolvedBrowseLayer: TransformableRequestConfig? = null
135
136
 
137
+ // The "view more" row title, cached on the same generation as the layers above — see
138
+ // viewMoreTitle(). Kept separate from them because it is resolved lazily (only a browse that
139
+ // actually appends a row asks for it), not eagerly alongside every request.
140
+ private var resolvedViewMoreTitleGeneration = -1
141
+ private var resolvedViewMoreTitle = DEFAULT_VIEW_MORE_TITLE
142
+
136
143
  /** Test-only accessors for the resolver-layer cache state (see ensureLayersResolved). */
137
144
  internal val layerGenerationForTest: Int
138
145
  get() = layerGeneration
@@ -562,6 +569,10 @@ class BrowserManager {
562
569
  /** Clears all cached content. */
563
570
  fun clearContentCache() {
564
571
  contentCache.evictAll()
572
+ // The search slot is content too: a locale switch or a re-auth changes titles, src, artwork
573
+ // and availability, and nothing else ever evicts it, so a repeated query returned
574
+ // pre-invalidation results forever. (Matches the Swift twin's clearContentCache.)
575
+ lastSearch = null
565
576
  // Bump the layer generation so request/browse resolver thunks are re-resolved on the next
566
577
  // request (invalidateAllContent → clearContentCache is the documented re-resolve trigger).
567
578
  layerGeneration += 1
@@ -594,6 +605,10 @@ class BrowserManager {
594
605
  // section — ADR 0010), then generate contextual paths and transform artwork
595
606
  // URLs. The output never carries `children`.
596
607
  val sections = resolvedTrack.normalizedSections ?: return resolvedTrack
608
+ // Dev diagnostic (ADR 0011) — before the blocks are folded, while each level is
609
+ // still attributable. Every page passes here whatever its source, and only on a
610
+ // cache miss. No-op outside debug builds.
611
+ InertStyleDiagnostic.warn(path, resolvedTrack.style, sections)
597
612
  // The contextual index is the flat page position — children concatenated in
598
613
  // section order (ADR 0009/0010).
599
614
  var flatIndex = 0
@@ -1121,6 +1136,14 @@ class BrowserManager {
1121
1136
 
1122
1137
  /** Internal path used for search */
1123
1138
  internal const val SEARCH_ROUTE_PATH = "__search__"
1139
+
1140
+ /**
1141
+ * Title of the appended "view more" row when the consumer sets no
1142
+ * [BrowserConfig.viewMoreTitle]. English in code, like [defaultFormattedError]'s error copy — a
1143
+ * default that renders beats a section whose `path` is unreachable, and it still resolves when
1144
+ * the JS runtime is gone.
1145
+ */
1146
+ internal const val DEFAULT_VIEW_MORE_TITLE = "View more"
1124
1147
  }
1125
1148
 
1126
1149
  /**
@@ -1296,6 +1319,33 @@ class BrowserManager {
1296
1319
  return resolvedRequestLayer
1297
1320
  }
1298
1321
 
1322
+ /**
1323
+ * The title for the "view more" row appended to a pathed section on a surface whose header cannot
1324
+ * be tapped (Android Auto).
1325
+ *
1326
+ * Resolved from [BrowserConfig.viewMoreTitle] once per content generation — the same generation
1327
+ * the resolver layers use, so `invalidateAllContent()` re-reads it and an app that switched
1328
+ * language mid-drive gets the new copy without reconfiguring. Falls back to
1329
+ * [DEFAULT_VIEW_MORE_TITLE] when unset, blank, or throwing: a row with the default copy still
1330
+ * reaches the section's page, an untitled one does not.
1331
+ */
1332
+ internal suspend fun viewMoreTitle(): String {
1333
+ val generation = layerGeneration
1334
+ if (resolvedViewMoreTitleGeneration == generation) return resolvedViewMoreTitle
1335
+ val title =
1336
+ try {
1337
+ config.viewMoreTitle?.invoke()?.await()?.takeIf { it.isNotBlank() }
1338
+ } catch (e: Exception) {
1339
+ Timber.e(e, "viewMoreTitle callback failed; falling back to the default")
1340
+ null
1341
+ } ?: DEFAULT_VIEW_MORE_TITLE
1342
+ // A newer generation started while awaiting — use this result, but don't cache it as current.
1343
+ if (generation != layerGeneration) return title
1344
+ resolvedViewMoreTitle = title
1345
+ resolvedViewMoreTitleGeneration = generation
1346
+ return title
1347
+ }
1348
+
1299
1349
  /**
1300
1350
  * Builds the HTTP request for an API-backed path by layering request (shared) → kind
1301
1351
  * (browse/search) → route configs. Each layer's transform receives the previous layer's output; a
@@ -1494,6 +1544,8 @@ data class BrowserConfig(
1494
1544
  // Behavior
1495
1545
  val singleTrack: Boolean = false,
1496
1546
  val androidControllerOfflineError: Boolean = true,
1547
+ // Titles the "view more" row appended to a pathed section. See BrowserManager.viewMoreTitle.
1548
+ val viewMoreTitle: (() -> Promise<String>)? = null,
1497
1549
  ) {
1498
1550
  /** Returns true if search functionality is configured (either callback or config). */
1499
1551
  val hasSearch: Boolean
@@ -0,0 +1,267 @@
1
+ package com.audiobrowser.browser
2
+
3
+ import com.audiobrowser.BuildConfig
4
+ import com.audiobrowser.util.toWireString
5
+ import com.margelo.nitro.audiobrowser.GridTile
6
+ import com.margelo.nitro.audiobrowser.Section
7
+ import com.margelo.nitro.audiobrowser.SectionStyle
8
+ import com.margelo.nitro.audiobrowser.StyleDisplay
9
+ import com.margelo.nitro.audiobrowser.TrackStyle
10
+ import timber.log.Timber
11
+
12
+ /**
13
+ * Dev diagnostic (ADR 0011): the declarations on a resolved page that can never render.
14
+ *
15
+ * The block model deliberately gave up compile-time invalid-combination errors — combinations are
16
+ * not invalid, they are *inert*, the way `grid-template` on a non-grid element is inert in CSS.
17
+ * This is the recovery: at page resolution every declaration its own block makes unreadable is
18
+ * reported once, with the effective context and the fix.
19
+ *
20
+ * The line it draws, and never crosses: only **structural** inertness — a declaration the resolved
21
+ * block itself renders unreadable, on every surface (`gridWrap` outside a grid, `cardTint` with no
22
+ * card treatment in scope). *Surface* inertness is never reported, because declarations are
23
+ * aspirational: `imageShape` on Android Auto, or a card treatment on a pre-26 CarPlay, is intended
24
+ * usage — the renderer drops what it can't draw.
25
+ *
26
+ * Runs where every page passes regardless of its source (static config, route callback, or fetched
27
+ * browse JSON), and only on a cache miss, so a mistake is reported once per resolution rather than
28
+ * once per serve. Mirrors `ios/Browser/InertStyleDiagnostic.swift`.
29
+ *
30
+ * Debug builds only: [warn] is the whole seam, so a release build never even walks the page (the
31
+ * trade is that a page served only to a release build — an internal track, production browse JSON —
32
+ * reports nothing; reproduce it against a debug build).
33
+ */
34
+ object InertStyleDiagnostic {
35
+ /**
36
+ * Reports every inert declaration on a resolved page. The one gate — the [BuildConfig] check is
37
+ * here rather than at the call sites so callers read unconditionally and the rule set stays free
38
+ * of build-configuration knowledge.
39
+ */
40
+ fun warn(path: String, pageStyle: SectionStyle?, sections: List<Section>) {
41
+ if (!BuildConfig.DEBUG) return
42
+ findings(path, pageStyle, sections).forEach { Timber.w(it) }
43
+ }
44
+
45
+ /**
46
+ * The declarations that carry a structural inertness condition.
47
+ *
48
+ * Two keys of the block are deliberately absent — `display` and `artworkRendering`, dispositioned
49
+ * below instead.
50
+ */
51
+ private enum class Property(val key: String, val isItemProperty: Boolean) {
52
+ // Container properties resolve by scope override (`section ?? page`) and never reach an item;
53
+ // item properties inherit (`track ?? section ?? page`), so they may also be declared per track.
54
+ GRID_WRAP("gridWrap", isItemProperty = false),
55
+ GRID_TILE("gridTile", isItemProperty = false),
56
+ IMAGE_SHAPE("imageShape", isItemProperty = true),
57
+ ACCESSORY_SYMBOL("accessorySymbol", isItemProperty = true),
58
+ CARD_TINT("cardTint", isItemProperty = true),
59
+ CARD_IMAGE("cardImage", isItemProperty = true);
60
+
61
+ /**
62
+ * Whether a value of this property, resolved into [context], has any rendering to affect. Each
63
+ * branch is the spec's own "inert unless" clause.
64
+ */
65
+ fun isInert(context: Context): Boolean =
66
+ when (this) {
67
+ GRID_WRAP,
68
+ GRID_TILE -> !context.isGrid
69
+ IMAGE_SHAPE -> !context.isGrid || context.isCard
70
+ ACCESSORY_SYMBOL -> context.isCard
71
+ CARD_TINT,
72
+ CARD_IMAGE -> !context.isCard
73
+ }
74
+
75
+ /** The rule the declaration falls foul of, stated positively. */
76
+ val rule: String
77
+ get() =
78
+ when (this) {
79
+ GRID_WRAP,
80
+ GRID_TILE -> "`$key` renders only in a grid"
81
+ IMAGE_SHAPE -> "only plain and condensed tiles have a shape"
82
+ ACCESSORY_SYMBOL -> "card tiles have no accessory slot"
83
+ CARD_TINT,
84
+ CARD_IMAGE -> "the card properties render only under `gridTile: 'card'`"
85
+ }
86
+
87
+ val hint: String
88
+ get() =
89
+ when (this) {
90
+ GRID_WRAP,
91
+ GRID_TILE -> "Declare `display: 'grid'` on the section or the page, or drop `$key`."
92
+ IMAGE_SHAPE -> "Render the items as plain or condensed grid tiles, or drop `imageShape`."
93
+ ACCESSORY_SYMBOL -> "Use `gridTile: 'plain'` or `'condensed'`, or drop `accessorySymbol`."
94
+ CARD_TINT,
95
+ CARD_IMAGE -> "Declare `display: 'grid'` with `gridTile: 'card'`, or drop `$key`."
96
+ }
97
+
98
+ fun declaredIn(style: SectionStyle?): Boolean =
99
+ when (this) {
100
+ GRID_WRAP -> style?.gridWrap != null
101
+ GRID_TILE -> style?.gridTile != null
102
+ IMAGE_SHAPE -> style?.imageShape != null
103
+ ACCESSORY_SYMBOL -> style?.accessorySymbol != null
104
+ CARD_TINT -> style?.cardTint != null
105
+ CARD_IMAGE -> style?.cardImage != null
106
+ }
107
+
108
+ // Nitro flattens the spec's `extends`, so the two blocks share no type — hence two lookups over
109
+ // the same key set rather than one interface.
110
+ fun declaredIn(style: TrackStyle?): Boolean =
111
+ when (this) {
112
+ // Container properties have no Track slot to be declared in.
113
+ GRID_WRAP,
114
+ GRID_TILE -> false
115
+ IMAGE_SHAPE -> style?.imageShape != null
116
+ ACCESSORY_SYMBOL -> style?.accessorySymbol != null
117
+ CARD_TINT -> style?.cardTint != null
118
+ CARD_IMAGE -> style?.cardImage != null
119
+ }
120
+ }
121
+
122
+ /**
123
+ * The rest of the block's keys, dispositioned rather than ruled on: `artworkRendering` has no
124
+ * structural inertness condition (artwork renders in every layout), and `display` is positional —
125
+ * inert only in the one position that opens no page (see [playableDisplayFinding]). Declared here
126
+ * so the spec-level completeness test can hold this file to the whole vocabulary: every key of
127
+ * the block is a rule, always renderable, or positional, and a newly added property is a decision
128
+ * rather than an omission.
129
+ */
130
+ val ALWAYS_RENDERABLE = listOf("artworkRendering")
131
+
132
+ val POSITIONAL = listOf("display")
133
+
134
+ /**
135
+ * A section's effective container context: the two container values every inertness condition is
136
+ * stated against.
137
+ *
138
+ * Folded through [StyleResolver] rather than re-deriving the scope override: the diagnostic
139
+ * predicts what a renderer will do with these declarations, so it has to resolve them the way the
140
+ * renderers do.
141
+ */
142
+ private class Context(section: SectionStyle?, page: SectionStyle?) {
143
+ private val folded = StyleResolver.sectionStyle(section, page)
144
+ val isGrid = folded.display == StyleDisplay.GRID
145
+ val tile = folded.gridTile ?: GridTile.PLAIN
146
+
147
+ /**
148
+ * Whether the card treatment is what this container renders — the `card*` family's condition.
149
+ */
150
+ val isCard: Boolean
151
+ get() = isGrid && tile == GridTile.CARD
152
+
153
+ /**
154
+ * The effective style, for the message — in the wire spelling the author types ([toWireString]
155
+ * is the decoder's own mapping). `gridTile` is quoted only where a grid gives it meaning:
156
+ * naming it on a list would be advice to fix the wrong property.
157
+ */
158
+ val described: String
159
+ get() = if (!isGrid) "display 'list'" else "display 'grid', gridTile '${tile.toWireString()}'"
160
+ }
161
+
162
+ /**
163
+ * Every inert declaration on a resolved page, as ready-to-log messages — pure, so the whole rule
164
+ * set is testable off-device; the caller logs.
165
+ *
166
+ * @param path the resolved path, named in every message.
167
+ * @param pageStyle the page's own block (`ResolvedTrack.style`).
168
+ * @param sections the page's normalized sections, blocks *unfolded* — the diagnostic attributes
169
+ * each finding to the level that declared it, so it must see the levels before [StyleResolver]
170
+ * merges them.
171
+ */
172
+ fun findings(path: String, pageStyle: SectionStyle?, sections: List<Section>): List<String> {
173
+ val findings = mutableListOf<String>()
174
+ // A page-level declaration is judged across the whole page: it is live if it renders in any
175
+ // section it reaches, however many others ignore it.
176
+ val pageReached = mutableSetOf<Property>()
177
+ val pageRendered = mutableSetOf<Property>()
178
+
179
+ sections.forEachIndexed { index, section ->
180
+ val context = Context(section.style, pageStyle)
181
+ val sectionLabel = label(section, index)
182
+
183
+ for (property in Property.entries) {
184
+ val inert = property.isInert(context)
185
+
186
+ if (property.declaredIn(section.style)) {
187
+ if (inert) {
188
+ findings +=
189
+ message(property, path, sectionLabel, "its effective style is ${context.described}")
190
+ }
191
+ } else if (property.declaredIn(pageStyle)) {
192
+ // The section takes the page's value (nothing closer overrode it).
193
+ pageReached += property
194
+ if (!inert) pageRendered += property
195
+ }
196
+
197
+ if (!property.isItemProperty || !inert) continue
198
+ val tracks = section.children.count { property.declaredIn(it.style) }
199
+ if (tracks > 0) {
200
+ findings +=
201
+ message(
202
+ property,
203
+ path,
204
+ "${trackCount(tracks)} in $sectionLabel",
205
+ "the section's effective style is ${context.described}",
206
+ )
207
+ }
208
+ }
209
+
210
+ playableDisplayFinding(section, path, sectionLabel)?.let { findings += it }
211
+ }
212
+
213
+ for (property in Property.entries) {
214
+ if (property in pageReached && property !in pageRendered) {
215
+ findings +=
216
+ message(property, path, "the page block", "no section that inherits it renders one")
217
+ }
218
+ }
219
+
220
+ return findings
221
+ }
222
+
223
+ /**
224
+ * `display` is positional, so it is never inert *in* a container — but on a track that renders
225
+ * playable (`src` wins the rendering) it promises the layout of a page that never opens.
226
+ */
227
+ private fun playableDisplayFinding(section: Section, path: String, label: String): String? {
228
+ val tracks = section.children.count { it.src != null && it.style?.display != null }
229
+ if (tracks == 0) return null
230
+ return message(
231
+ path = path,
232
+ owner = "${trackCount(tracks)} in $label",
233
+ key = "display",
234
+ reason =
235
+ "`display` is the layout promise for the page a track opens, and a track rendered playable " +
236
+ "opens none",
237
+ hint = "Drop `display`, or give the track a `path` if it should be browsable.",
238
+ )
239
+ }
240
+
241
+ private fun message(property: Property, path: String, owner: String, scope: String): String =
242
+ message(
243
+ path = path,
244
+ owner = owner,
245
+ key = property.key,
246
+ reason = "${property.rule}, but $scope",
247
+ hint = property.hint,
248
+ )
249
+
250
+ /**
251
+ * The one message skeleton — every finding names the page, the declaration, who declared it, why
252
+ * it can't render, and what to do about it.
253
+ */
254
+ private fun message(
255
+ path: String,
256
+ owner: String,
257
+ key: String,
258
+ reason: String,
259
+ hint: String,
260
+ ): String =
261
+ "Inert style declaration on page '$path': `style.$key` on $owner never renders — $reason. $hint"
262
+
263
+ private fun trackCount(count: Int): String = "$count ${if (count == 1) "track" else "tracks"}"
264
+
265
+ private fun label(section: Section, index: Int): String =
266
+ section.title?.takeIf { it.isNotEmpty() }?.let { "section '$it'" } ?: "section #${index + 1}"
267
+ }
@@ -1,6 +1,11 @@
1
1
  package com.audiobrowser.browser
2
2
 
3
+ import com.audiobrowser.util.EMPTY_SECTION_STYLE
4
+ import com.audiobrowser.util.EMPTY_TRACK_STYLE
3
5
  import com.audiobrowser.util.toArtworkRendering
6
+ import com.audiobrowser.util.toCardImage
7
+ import com.audiobrowser.util.toGridTile
8
+ import com.audiobrowser.util.toImageShape
4
9
  import com.audiobrowser.util.toStyleDisplay
5
10
  import com.margelo.nitro.audiobrowser.CarPlaySiriListButtonPosition
6
11
  import com.margelo.nitro.audiobrowser.ResolvedTrack
@@ -35,7 +40,6 @@ data class JsonTrackRequest(
35
40
  @Serializable
36
41
  data class JsonSection(
37
42
  val title: String? = null,
38
- val subtitle: String? = null,
39
43
  val style: JsonElement? = null,
40
44
  val path: String? = null,
41
45
  val children: List<JsonTrack>,
@@ -106,19 +110,32 @@ private fun JsonObject.booleanField(key: String): Boolean? =
106
110
  */
107
111
  private fun JsonElement?.toTrackStyle(): TrackStyle? =
108
112
  (this as? JsonObject)?.let { obj ->
109
- val display = obj.stringField("display").toStyleDisplay()
110
- val artworkRendering = obj.stringField("artworkRendering").toArtworkRendering()
111
- if (display == null && artworkRendering == null) null
112
- else TrackStyle(display = display, artworkRendering = artworkRendering)
113
+ TrackStyle(
114
+ display = obj.stringField("display").toStyleDisplay(),
115
+ artworkRendering = obj.stringField("artworkRendering").toArtworkRendering(),
116
+ imageShape = obj.stringField("imageShape").toImageShape(),
117
+ // 'none' is a legitimate accessory value (the inheritance escape);
118
+ // only emptiness collapses.
119
+ accessorySymbol = obj.stringField("accessorySymbol")?.takeIf { it.isNotEmpty() },
120
+ cardTint = obj.stringField("cardTint")?.takeIf { it.isNotEmpty() },
121
+ cardImage = obj.stringField("cardImage").toCardImage(),
122
+ )
123
+ .takeIf { it != EMPTY_TRACK_STYLE }
113
124
  }
114
125
 
115
126
  private fun JsonElement?.toSectionStyle(): SectionStyle? =
116
127
  (this as? JsonObject)?.let { obj ->
117
- val display = obj.stringField("display").toStyleDisplay()
118
- val artworkRendering = obj.stringField("artworkRendering").toArtworkRendering()
119
- val gridWrap = obj.booleanField("gridWrap")
120
- if (display == null && artworkRendering == null && gridWrap == null) null
121
- else SectionStyle(display = display, artworkRendering = artworkRendering, gridWrap = gridWrap)
128
+ SectionStyle(
129
+ display = obj.stringField("display").toStyleDisplay(),
130
+ artworkRendering = obj.stringField("artworkRendering").toArtworkRendering(),
131
+ imageShape = obj.stringField("imageShape").toImageShape(),
132
+ accessorySymbol = obj.stringField("accessorySymbol")?.takeIf { it.isNotEmpty() },
133
+ cardTint = obj.stringField("cardTint")?.takeIf { it.isNotEmpty() },
134
+ cardImage = obj.stringField("cardImage").toCardImage(),
135
+ gridWrap = obj.booleanField("gridWrap"),
136
+ gridTile = obj.stringField("gridTile").toGridTile(),
137
+ )
138
+ .takeIf { it != EMPTY_SECTION_STYLE }
122
139
  }
123
140
 
124
141
  private fun String?.toCarPlaySiriListButtonPosition(): CarPlaySiriListButtonPosition? {
@@ -132,7 +149,6 @@ private fun String?.toCarPlaySiriListButtonPosition(): CarPlaySiriListButtonPosi
132
149
  fun JsonSection.toNitro(): Section {
133
150
  return Section(
134
151
  title = title,
135
- subtitle = subtitle,
136
152
  style = style.toSectionStyle(),
137
153
  path = path,
138
154
  children = children.map { it.toNitro() }.toTypedArray(),
@@ -59,7 +59,7 @@ object SectionScope {
59
59
  * search results — ADR 0010).
60
60
  */
61
61
  fun untitledSection(children: Array<Track>): Section =
62
- Section(title = null, subtitle = null, style = null, path = null, children = children)
62
+ Section(title = null, style = null, path = null, children = children)
63
63
 
64
64
  /**
65
65
  * The canonical sectioned shape: `sections` wins when present; plain `children` is authoring sugar
@@ -29,7 +29,12 @@ object StyleResolver {
29
29
  SectionStyle(
30
30
  display = section?.display ?: page?.display,
31
31
  artworkRendering = section?.artworkRendering ?: page?.artworkRendering,
32
+ imageShape = section?.imageShape ?: page?.imageShape,
33
+ accessorySymbol = section?.accessorySymbol ?: page?.accessorySymbol,
34
+ cardTint = section?.cardTint ?: page?.cardTint,
35
+ cardImage = section?.cardImage ?: page?.cardImage,
32
36
  gridWrap = section?.gridWrap ?: page?.gridWrap,
37
+ gridTile = section?.gridTile ?: page?.gridTile,
33
38
  )
34
39
 
35
40
  /**
@@ -45,5 +50,11 @@ object StyleResolver {
45
50
  // renderer can mistake the handle's page promise for this item's own layout.
46
51
  display = null,
47
52
  artworkRendering = track?.artworkRendering ?: section?.artworkRendering,
53
+ imageShape = track?.imageShape ?: section?.imageShape,
54
+ // 'none' resolves like any value — a renderer treats it as "no accessory, derived
55
+ // behavior" (the inheritance escape). No Android surface draws these properties today.
56
+ accessorySymbol = track?.accessorySymbol ?: section?.accessorySymbol,
57
+ cardTint = track?.cardTint ?: section?.cardTint,
58
+ cardImage = track?.cardImage ?: section?.cardImage,
48
59
  )
49
60
  }
@@ -30,7 +30,17 @@ fun ResolvedTrack.toTrack(): Track =
30
30
  // properties (gridWrap) have no meaning on a plain Track, so they drop —
31
31
  // Nitro flattens the spec's `extends`, making this narrowing a hand-written
32
32
  // field list rather than an upcast (ADR 0011).
33
- style = style?.let { TrackStyle(display = it.display, artworkRendering = it.artworkRendering) },
33
+ style =
34
+ style?.let {
35
+ TrackStyle(
36
+ display = it.display,
37
+ artworkRendering = it.artworkRendering,
38
+ imageShape = it.imageShape,
39
+ accessorySymbol = it.accessorySymbol,
40
+ cardTint = it.cardTint,
41
+ cardImage = it.cardImage,
42
+ )
43
+ },
34
44
  disabled = disabled,
35
45
  favorited = favorited,
36
46
  live = live,
@@ -20,7 +20,7 @@ data class PlayerUpdateOptions(
20
20
  var progressUpdateEventInterval: Double? = null,
21
21
 
22
22
  // Player capabilities - most enabled by default, only false values disable
23
- // Exceptions: bookmark, jumpForward, jumpBackward default to false
23
+ // Exceptions: jumpForward, jumpBackward default to false
24
24
  var capabilities: PlayerCapabilities =
25
25
  PlayerCapabilities(
26
26
  play = null,