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
@@ -32,7 +32,6 @@ import com.margelo.nitro.audiobrowser.PlayerCapabilities
32
32
  import com.margelo.nitro.audiobrowser.RemoteButtonLayout
33
33
  import com.margelo.nitro.audiobrowser.SearchParams
34
34
  import com.margelo.nitro.audiobrowser.Section
35
- import com.margelo.nitro.audiobrowser.StyleDisplay
36
35
  import com.margelo.nitro.audiobrowser.Track
37
36
  import kotlinx.coroutines.CancellationException
38
37
  import kotlinx.coroutines.CoroutineScope
@@ -419,33 +418,33 @@ class MediaSessionCallback(private val player: Player) :
419
418
  /**
420
419
  * Flattens a page's sections to MediaItems for browse delivery — the Media3 boundary where
421
420
  * sections die (ADR 0010): the MediaBrowser protocol has no section node, so each child is
422
- * stamped with its owning section's title/style hints. A grid-displayed section with a `path`
423
- * gains a browsable "view all" link under the same header. Http(s) artwork routes through the
424
- * content:// provider so Android Auto can load it via the ArtworkContentProvider.
421
+ * stamped with its owning section's title/style hints. A section with a `path` gains a browsable
422
+ * "view more" row under the same header — at every `display`, because no Android Auto header is
423
+ * tappable at any of them, so the row is the section's only "view all" affordance here. Http(s)
424
+ * artwork routes through the content:// provider so Android Auto can load it via the
425
+ * ArtworkContentProvider.
425
426
  */
426
- private fun toMediaItems(sections: List<Section>): List<MediaItem> {
427
+ private suspend fun toMediaItems(sections: List<Section>): List<MediaItem> {
427
428
  val registry = player.browseArtworkRegistry
428
429
  val authority = com.audiobrowser.util.ArtworkUris.authorityFor(player.context.packageName)
429
430
  return sections.flatMap { section ->
430
431
  // Android Auto has no disabled affordance, so an unavailable track hides
431
432
  // — never a normal-looking dead row (Track.disabled's rendering ladder).
432
- val visibleChildren = section.children.filter { it.disabled != true }
433
- val items =
434
- visibleChildren.map { TrackFactory.toBrowseMediaItem(it, registry, authority, section) }
435
- val isTileSection = section.style?.display == StyleDisplay.GRID
436
- // No "view all" under an empty section — CarPlay skips empty sections
433
+ val children = section.children.filter { it.disabled != true }
434
+ val items = children.map { TrackFactory.toBrowseMediaItem(it, registry, authority, section) }
435
+ // No "view more" under an empty section — CarPlay skips empty sections
437
436
  // outright, and a lone navigation tile under a header is a dead end.
438
- if (isTileSection && section.path != null && visibleChildren.isNotEmpty()) {
439
- items +
440
- TrackFactory.toBrowseMediaItem(
441
- TrackFactory.navigationTrack(section),
442
- registry,
443
- authority,
444
- section,
445
- )
446
- } else {
447
- items
448
- }
437
+ if (section.path == null || children.isEmpty()) return@flatMap items
438
+ // The browser is already up at every call site, so awaiting it here is a field read, and
439
+ // viewMoreTitle is memoized per content generation — both are free after the first row.
440
+ val title = player.awaitBrowser().browserManager.viewMoreTitle()
441
+ items +
442
+ TrackFactory.toBrowseMediaItem(
443
+ TrackFactory.navigationTrack(section, title),
444
+ registry,
445
+ authority,
446
+ section,
447
+ )
449
448
  }
450
449
  }
451
450
 
@@ -453,7 +452,7 @@ class MediaSessionCallback(private val player: Player) :
453
452
  * Flat lists (tabs, search results) convert as one untitled list section — which contributes no
454
453
  * group or style hints — so exactly one Track→MediaItem path exists at the Media3 boundary.
455
454
  */
456
- private fun toFlatMediaItems(tracks: List<Track>): List<MediaItem> =
455
+ private suspend fun toFlatMediaItems(tracks: List<Track>): List<MediaItem> =
457
456
  toMediaItems(listOf(untitledSection(tracks.toTypedArray())))
458
457
 
459
458
  override fun onGetItem(
@@ -4,8 +4,11 @@ import android.content.Context
4
4
  import android.content.SharedPreferences
5
5
  import androidx.core.content.edit
6
6
  import androidx.media3.common.C
7
+ import com.audiobrowser.util.EMPTY_TRACK_STYLE
7
8
  import com.audiobrowser.util.artworkOf
8
9
  import com.audiobrowser.util.toArtworkRendering
10
+ import com.audiobrowser.util.toCardImage
11
+ import com.audiobrowser.util.toImageShape
9
12
  import com.audiobrowser.util.toStyleDisplay
10
13
  import com.audiobrowser.util.toWireString
11
14
  import com.audiobrowser.util.url
@@ -164,7 +167,11 @@ class PlaybackStateStore(private val player: Player) {
164
167
  // Apply player settings from persisted state
165
168
  player.repeatMode = state.repeatMode
166
169
  player.shuffleMode = state.shuffleEnabled
167
- player.playbackSpeed = state.playbackSpeed
170
+ // Guarded because this input is untrusted: ExoPlayer's setPlaybackSpeed rejects a
171
+ // non-positive speed with IllegalArgumentException, and a snapshot holding one (a
172
+ // corrupted prefs file — the normal write path can't produce it) would then throw on
173
+ // every resume, permanently, since the bad value stays persisted.
174
+ if (state.playbackSpeed > 0f) player.playbackSpeed = state.playbackSpeed
168
175
 
169
176
  Timber.d(
170
177
  "Restored player settings: repeatMode=${state.repeatMode}, shuffle=${state.shuffleEnabled}, speed=${state.playbackSpeed}"
@@ -216,6 +223,10 @@ class PlaybackStateStore(private val player: Player) {
216
223
  JSONObject().apply {
217
224
  put("display", style.display?.toWireString())
218
225
  put("artworkRendering", style.artworkRendering?.toWireString())
226
+ put("imageShape", style.imageShape?.toWireString())
227
+ put("accessorySymbol", style.accessorySymbol)
228
+ put("cardTint", style.cardTint)
229
+ put("cardImage", style.cardImage?.toWireString())
219
230
  }
220
231
  },
221
232
  )
@@ -254,11 +265,15 @@ class PlaybackStateStore(private val player: Player) {
254
265
  TrackStyle(
255
266
  display = style.optString("display").toStyleDisplay(),
256
267
  artworkRendering = style.optString("artworkRendering").toArtworkRendering(),
268
+ imageShape = style.optString("imageShape").toImageShape(),
269
+ accessorySymbol = style.optString("accessorySymbol").takeIf { it.isNotEmpty() },
270
+ cardTint = style.optString("cardTint").takeIf { it.isNotEmpty() },
271
+ cardImage = style.optString("cardImage").toCardImage(),
257
272
  )
258
273
  }
259
274
  // An empty block collapses to "no declaration" — one shape on
260
275
  // every platform, matching the browse decoders.
261
- ?.takeIf { it.display != null || it.artworkRendering != null },
276
+ ?.takeIf { it != EMPTY_TRACK_STYLE },
262
277
  disabled =
263
278
  if (obj.has("disabled") && !obj.isNull("disabled")) obj.getBoolean("disabled")
264
279
  else null,
@@ -668,6 +668,9 @@ class Player(internal val context: Context) {
668
668
  ) {
669
669
  val mediaItems = TrackFactory.toMedia3(tracks).toMutableList()
670
670
  exoPlayer.setMediaItems(mediaItems, startIndex, startPositionMs)
671
+ if (mediaItems.isNotEmpty()) {
672
+ exoPlayer.setShuffleOrder(shuffleOrderLedBy(startIndex, mediaItems.size))
673
+ }
671
674
  queueSourcePath = sourcePath
672
675
  exoPlayer.prepare()
673
676
  }
@@ -0,0 +1,30 @@
1
+ package com.audiobrowser.player
2
+
3
+ import androidx.media3.exoplayer.source.ShuffleOrder
4
+ import kotlin.random.Random
5
+
6
+ /**
7
+ * A shuffle order over [count] items with [startIndex] pinned to shuffle position 0.
8
+ *
9
+ * Media3 does not do this for us. `ExoPlayerImpl.setMediaSources` hands the start index to
10
+ * `ShuffleOrder.cloneAndSet(insertionCount, startIndex)`, but that is an interface default of
11
+ * `cloneAndClear().cloneAndInsert(0, insertionCount)` — the start index is discarded — and
12
+ * `DefaultShuffleOrder` does not override it (verified against the pinned media3 build). So with
13
+ * shuffle on, the track playback starts from lands at a random position in the order, and a 1-in-N
14
+ * share of queues put it last: `nextMediaItemIndex` is then `INDEX_UNSET` from the very first
15
+ * track, so auto-advance ends the queue after one track and fires `onQueueEnded`. The
16
+ * voice-assistant cold resume expands a queue exactly this way, so it sits squarely on that path.
17
+ *
18
+ * Applied unconditionally by `Player.setQueue`, like the Swift twin's `adoptInitialCurrent`:
19
+ * enabling shuffle later does not regenerate the order, so it has to be sane whether or not shuffle
20
+ * is on right now.
21
+ */
22
+ internal fun shuffleOrderLedBy(startIndex: Int, count: Int, random: Random = Random): ShuffleOrder {
23
+ val first = startIndex.coerceIn(0, (count - 1).coerceAtLeast(0))
24
+ val indices = IntArray(count)
25
+ if (count > 0) {
26
+ indices[0] = first
27
+ ((0 until count) - first).shuffled().forEachIndexed { i, index -> indices[i + 1] = index }
28
+ }
29
+ return ShuffleOrder.DefaultShuffleOrder(indices, random.nextLong())
30
+ }
@@ -1,7 +1,39 @@
1
1
  package com.audiobrowser.util
2
2
 
3
3
  import com.margelo.nitro.audiobrowser.ArtworkRendering
4
+ import com.margelo.nitro.audiobrowser.CardImage
5
+ import com.margelo.nitro.audiobrowser.GridTile
6
+ import com.margelo.nitro.audiobrowser.ImageShape
7
+ import com.margelo.nitro.audiobrowser.SectionStyle
4
8
  import com.margelo.nitro.audiobrowser.StyleDisplay
9
+ import com.margelo.nitro.audiobrowser.TrackStyle
10
+
11
+ /**
12
+ * The all-nil blocks a decode collapses to "no declaration". Every field is listed exactly once
13
+ * here — the decoders and the persistence restore compare against these instead of re-enumerating
14
+ * the fields in each guard (a constructor gaining a field breaks this file, not silently a guard).
15
+ */
16
+ internal val EMPTY_TRACK_STYLE =
17
+ TrackStyle(
18
+ display = null,
19
+ artworkRendering = null,
20
+ imageShape = null,
21
+ accessorySymbol = null,
22
+ cardTint = null,
23
+ cardImage = null,
24
+ )
25
+
26
+ internal val EMPTY_SECTION_STYLE =
27
+ SectionStyle(
28
+ display = null,
29
+ artworkRendering = null,
30
+ imageShape = null,
31
+ accessorySymbol = null,
32
+ cardTint = null,
33
+ cardImage = null,
34
+ gridWrap = null,
35
+ gridTile = null,
36
+ )
5
37
 
6
38
  /**
7
39
  * The style block's wire strings (ADR 0011) ↔ Nitro enums, shared by the browse decoder and the
@@ -33,3 +65,44 @@ internal fun String?.toArtworkRendering(): ArtworkRendering? =
33
65
  "stencil" -> ArtworkRendering.STENCIL
34
66
  else -> null
35
67
  }
68
+
69
+ internal fun ImageShape.toWireString(): String =
70
+ when (this) {
71
+ ImageShape.CIRCULAR -> "circular"
72
+ ImageShape.ROUNDED_RECTANGLE -> "rounded-rectangle"
73
+ }
74
+
75
+ internal fun String?.toImageShape(): ImageShape? =
76
+ when (this?.takeIf { it.isNotEmpty() }?.lowercase()) {
77
+ "circular" -> ImageShape.CIRCULAR
78
+ "rounded-rectangle" -> ImageShape.ROUNDED_RECTANGLE
79
+ else -> null
80
+ }
81
+
82
+ internal fun GridTile.toWireString(): String =
83
+ when (this) {
84
+ GridTile.PLAIN -> "plain"
85
+ GridTile.CARD -> "card"
86
+ GridTile.CONDENSED -> "condensed"
87
+ }
88
+
89
+ internal fun String?.toGridTile(): GridTile? =
90
+ when (this?.takeIf { it.isNotEmpty() }?.lowercase()) {
91
+ "plain" -> GridTile.PLAIN
92
+ "card" -> GridTile.CARD
93
+ "condensed" -> GridTile.CONDENSED
94
+ else -> null
95
+ }
96
+
97
+ internal fun CardImage.toWireString(): String =
98
+ when (this) {
99
+ CardImage.NORMAL -> "normal"
100
+ CardImage.BACKGROUND -> "background"
101
+ }
102
+
103
+ internal fun String?.toCardImage(): CardImage? =
104
+ when (this?.takeIf { it.isNotEmpty() }?.lowercase()) {
105
+ "normal" -> CardImage.NORMAL
106
+ "background" -> CardImage.BACKGROUND
107
+ else -> null
108
+ }
@@ -25,13 +25,16 @@ object TrackFactory {
25
25
  }
26
26
 
27
27
  /**
28
- * A synthetic browsable Track for a section's "view all" surface — the section has a path, title,
29
- * and label, but no Track (ADR 0010). It carries the section's declared item block as its own,
28
+ * A synthetic browsable Track for a section's "view more" row — the section has a path but no
29
+ * Track to hang it on (ADR 0010). It carries the section's declared item block as its own,
30
30
  * `display` included: the section's declaration about this content's layout is the only declared
31
31
  * promise the "view all" page can ever have (there is no consumer-authored handle to declare one
32
32
  * on), and without it Android Auto would always render that page as a list.
33
+ *
34
+ * [title] is the consumer's `viewMoreTitle`, one string for every section — not the section's own
35
+ * title, which the group header above the row already shows.
33
36
  */
34
- fun navigationTrack(section: Section): Track =
37
+ fun navigationTrack(section: Section, title: String): Track =
35
38
  Track(
36
39
  id = null,
37
40
  path = section.path,
@@ -39,8 +42,8 @@ object TrackFactory {
39
42
  artwork = null,
40
43
  artworkSource = null,
41
44
  request = null,
42
- title = section.title ?: "",
43
- subtitle = section.subtitle,
45
+ title = title,
46
+ subtitle = null,
44
47
  artist = null,
45
48
  albumPath = null,
46
49
  album = null,
@@ -49,7 +52,14 @@ object TrackFactory {
49
52
  duration = null,
50
53
  style =
51
54
  section.style?.let {
52
- TrackStyle(display = it.display, artworkRendering = it.artworkRendering)
55
+ TrackStyle(
56
+ display = it.display,
57
+ artworkRendering = it.artworkRendering,
58
+ imageShape = it.imageShape,
59
+ accessorySymbol = it.accessorySymbol,
60
+ cardTint = it.cardTint,
61
+ cardImage = it.cardImage,
62
+ )
53
63
  },
54
64
  disabled = null,
55
65
  favorited = null,
@@ -1,10 +1,45 @@
1
1
  <?xml version="1.0" encoding="utf-8"?>
2
+ <!--
3
+ Default copy for the two error tiles the library invents in the Android Auto
4
+ browse list. These rows have no consumer-authored Track behind them, so there
5
+ is nothing in the browse tree to read a title from; English here because a
6
+ default that renders beats a blank row.
7
+
8
+ Resources rather than a browser-config option — the route the library's
9
+ cross-platform copy takes (`formatNavigationError`, `carPlayLoadingTitle`,
10
+ `viewMoreTitle`) — because these two are not cross-platform. They exist only
11
+ because Media3 drops the message from `LibraryResult.ofError` on the legacy
12
+ browse bridge, so a row is the sole signal available; CarPlay has no
13
+ counterpart, stating failures in the list's centered empty view instead
14
+ (CarPlayController.showMessage). Android-only copy in the Android-idiomatic
15
+ place: an app re-skins either by declaring the same name in its own
16
+ strings.xml, and a `values-<locale>/` file can add translations later without
17
+ touching the API.
18
+ -->
2
19
  <resources>
3
- <!-- Error message shown in Android Auto when network is offline -->
20
+ <!--
21
+ The offline tile: shown in place of a page's contents when the device is
22
+ offline and the consumer left `androidControllerOfflineError` on. Distinct
23
+ from the browse-error tile below — this one is used only when the network
24
+ monitor says we are actually offline, so it may name the cause. The
25
+ subtitle is the tile's second line.
26
+ -->
4
27
  <string name="audio_browser_offline_error">No internet connection</string>
5
28
  <string name="audio_browser_offline_error_subtitle">Check your connection and try again</string>
6
29
 
7
- <!-- Generic error message shown in Android Auto when browsing fails -->
30
+ <!--
31
+ The browse-error tile: shown when a browse fails while ONLINE — a server
32
+ error, a bad status, a timeout waiting for the JS browser. Deliberately
33
+ vague, and deliberately not "no internet connection": the genuinely-offline
34
+ case is caught earlier and gets the tile above, so anything reaching this
35
+ one has a working connection and would be misdiagnosed by that wording.
36
+
37
+ A tile rather than an error dialog because Media3 drops the message from
38
+ `LibraryResult.ofError` on the legacy browse bridge, which would leave a
39
+ bare "No items" screen. Verified on a head unit (2026-06): the Android Auto
40
+ browse list never renders error text by any other route, so tiles are the
41
+ only in-browse signal the library controls.
42
+ -->
8
43
  <string name="audio_browser_browse_error">Something went wrong</string>
9
44
  <string name="audio_browser_browse_error_subtitle">Please try again later</string>
10
45
  </resources>
@@ -1,15 +1,21 @@
1
1
  package com.audiobrowser
2
2
 
3
3
  import com.audiobrowser.util.artworkOf
4
+ import com.margelo.nitro.audiobrowser.ArtworkRendering
4
5
  import com.margelo.nitro.audiobrowser.ArtworkRequestConfig
6
+ import com.margelo.nitro.audiobrowser.CardImage
7
+ import com.margelo.nitro.audiobrowser.GridTile
5
8
  import com.margelo.nitro.audiobrowser.ImageQueryParams
9
+ import com.margelo.nitro.audiobrowser.ImageShape
6
10
  import com.margelo.nitro.audiobrowser.ImageSource
7
11
  import com.margelo.nitro.audiobrowser.MediaRequestConfig
8
12
  import com.margelo.nitro.audiobrowser.NativeRouteEntry
9
13
  import com.margelo.nitro.audiobrowser.ResolvedTrack
10
14
  import com.margelo.nitro.audiobrowser.Section
11
15
  import com.margelo.nitro.audiobrowser.SectionStyle
16
+ import com.margelo.nitro.audiobrowser.StyleDisplay
12
17
  import com.margelo.nitro.audiobrowser.Track
18
+ import com.margelo.nitro.audiobrowser.TrackStyle
13
19
  import com.margelo.nitro.audiobrowser.TransformableRequestConfig
14
20
 
15
21
  /**
@@ -28,6 +34,7 @@ object TestFixtures {
28
34
  album: String? = null,
29
35
  favorited: Boolean? = null,
30
36
  disabled: Boolean? = null,
37
+ style: TrackStyle? = null,
31
38
  ) =
32
39
  Track(
33
40
  id = id,
@@ -44,7 +51,7 @@ object TestFixtures {
44
51
  description = null,
45
52
  genre = null,
46
53
  duration = null,
47
- style = null,
54
+ style = style,
48
55
  disabled = disabled,
49
56
  favorited = favorited,
50
57
  live = null,
@@ -52,12 +59,11 @@ object TestFixtures {
52
59
 
53
60
  /** A page [Section] — plain list by default. */
54
61
  fun section(
55
- children: Array<Track>,
62
+ children: Array<Track> = emptyArray(),
56
63
  title: String? = null,
57
- subtitle: String? = null,
58
64
  style: SectionStyle? = null,
59
65
  path: String? = null,
60
- ) = Section(title = title, subtitle = subtitle, style = style, path = path, children = children)
66
+ ) = Section(title = title, style = style, path = path, children = children)
61
67
 
62
68
  fun transformableConfig(
63
69
  baseUrl: String? = null,
@@ -109,6 +115,7 @@ object TestFixtures {
109
115
  path: String = "https://api.example.com/channel/abc",
110
116
  artwork: String? = null,
111
117
  artworkSource: ImageSource? = null,
118
+ style: TrackStyle? = null,
112
119
  ) =
113
120
  Track(
114
121
  id = null,
@@ -125,7 +132,7 @@ object TestFixtures {
125
132
  description = null,
126
133
  genre = null,
127
134
  duration = null,
128
- style = null,
135
+ style = style,
129
136
  disabled = null,
130
137
  favorited = null,
131
138
  live = null,
@@ -201,4 +208,42 @@ object TestFixtures {
201
208
  contentType = null,
202
209
  userAgent = null,
203
210
  )
211
+
212
+ fun trackStyle(
213
+ display: StyleDisplay? = null,
214
+ artworkRendering: ArtworkRendering? = null,
215
+ imageShape: ImageShape? = null,
216
+ accessorySymbol: String? = null,
217
+ cardTint: String? = null,
218
+ cardImage: CardImage? = null,
219
+ ) =
220
+ TrackStyle(
221
+ display = display,
222
+ artworkRendering = artworkRendering,
223
+ imageShape = imageShape,
224
+ accessorySymbol = accessorySymbol,
225
+ cardTint = cardTint,
226
+ cardImage = cardImage,
227
+ )
228
+
229
+ fun sectionStyle(
230
+ display: StyleDisplay? = null,
231
+ artworkRendering: ArtworkRendering? = null,
232
+ imageShape: ImageShape? = null,
233
+ accessorySymbol: String? = null,
234
+ cardTint: String? = null,
235
+ cardImage: CardImage? = null,
236
+ gridWrap: Boolean? = null,
237
+ gridTile: GridTile? = null,
238
+ ) =
239
+ SectionStyle(
240
+ gridWrap = gridWrap,
241
+ gridTile = gridTile,
242
+ display = display,
243
+ artworkRendering = artworkRendering,
244
+ imageShape = imageShape,
245
+ accessorySymbol = accessorySymbol,
246
+ cardTint = cardTint,
247
+ cardImage = cardImage,
248
+ )
204
249
  }
@@ -144,4 +144,13 @@ class BrowserManagerLayerResolutionTest {
144
144
  assertSame(request, browserManager.resolvedRequestLayerForTest)
145
145
  assertNull(browserManager.resolvedBrowseLayerForTest)
146
146
  }
147
+
148
+ @Test
149
+ fun `viewMoreTitle falls back to the in-code default when unconfigured`() = runTest {
150
+ // The only viewMoreTitle path a JVM test can reach — a configured callback returns a Nitro
151
+ // Promise, see the resolver-thunk gap noted above.
152
+ browserManager.config = BrowserConfig()
153
+
154
+ assertEquals(BrowserManager.DEFAULT_VIEW_MORE_TITLE, browserManager.viewMoreTitle())
155
+ }
147
156
  }
@@ -4,6 +4,7 @@ import androidx.media3.common.C
4
4
  import androidx.media3.common.MediaItem
5
5
  import com.audiobrowser.TestFixtures.resolvedTrack
6
6
  import com.audiobrowser.TestFixtures.section
7
+ import com.audiobrowser.TestFixtures.sectionStyle
7
8
  import com.audiobrowser.TestFixtures.staticRoute
8
9
  import com.audiobrowser.TestFixtures.track
9
10
  import com.audiobrowser.util.BrowserPathHelper
@@ -183,7 +184,7 @@ class BrowserManagerTest {
183
184
  section(
184
185
  title = "Row",
185
186
  style =
186
- com.margelo.nitro.audiobrowser.SectionStyle(
187
+ sectionStyle(
187
188
  display = com.margelo.nitro.audiobrowser.StyleDisplay.GRID,
188
189
  artworkRendering = null,
189
190
  gridWrap = false,
@@ -252,7 +253,7 @@ class BrowserManagerTest {
252
253
  section(
253
254
  title = "Most Played",
254
255
  style =
255
- com.margelo.nitro.audiobrowser.SectionStyle(
256
+ sectionStyle(
256
257
  display = com.margelo.nitro.audiobrowser.StyleDisplay.GRID,
257
258
  artworkRendering = null,
258
259
  gridWrap = false,