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,10 +32,17 @@
32
32
  namespace margelo::nitro::audiobrowser { enum class StyleDisplay; }
33
33
  // Forward declaration of `ArtworkRendering` to properly resolve imports.
34
34
  namespace margelo::nitro::audiobrowser { enum class ArtworkRendering; }
35
+ // Forward declaration of `ImageShape` to properly resolve imports.
36
+ namespace margelo::nitro::audiobrowser { enum class ImageShape; }
37
+ // Forward declaration of `CardImage` to properly resolve imports.
38
+ namespace margelo::nitro::audiobrowser { enum class CardImage; }
35
39
 
36
40
  #include "StyleDisplay.hpp"
37
41
  #include <optional>
38
42
  #include "ArtworkRendering.hpp"
43
+ #include "ImageShape.hpp"
44
+ #include <string>
45
+ #include "CardImage.hpp"
39
46
 
40
47
  namespace margelo::nitro::audiobrowser {
41
48
 
@@ -46,10 +53,14 @@ namespace margelo::nitro::audiobrowser {
46
53
  public:
47
54
  std::optional<StyleDisplay> display SWIFT_PRIVATE;
48
55
  std::optional<ArtworkRendering> artworkRendering SWIFT_PRIVATE;
56
+ std::optional<ImageShape> imageShape SWIFT_PRIVATE;
57
+ std::optional<std::string> accessorySymbol SWIFT_PRIVATE;
58
+ std::optional<std::string> cardTint SWIFT_PRIVATE;
59
+ std::optional<CardImage> cardImage SWIFT_PRIVATE;
49
60
 
50
61
  public:
51
62
  TrackStyle() = default;
52
- explicit TrackStyle(std::optional<StyleDisplay> display, std::optional<ArtworkRendering> artworkRendering): display(display), artworkRendering(artworkRendering) {}
63
+ explicit TrackStyle(std::optional<StyleDisplay> display, std::optional<ArtworkRendering> artworkRendering, std::optional<ImageShape> imageShape, std::optional<std::string> accessorySymbol, std::optional<std::string> cardTint, std::optional<CardImage> cardImage): display(display), artworkRendering(artworkRendering), imageShape(imageShape), accessorySymbol(accessorySymbol), cardTint(cardTint), cardImage(cardImage) {}
53
64
 
54
65
  public:
55
66
  friend bool operator==(const TrackStyle& lhs, const TrackStyle& rhs) = default;
@@ -66,13 +77,21 @@ namespace margelo::nitro {
66
77
  jsi::Object obj = arg.asObject(runtime);
67
78
  return margelo::nitro::audiobrowser::TrackStyle(
68
79
  JSIConverter<std::optional<margelo::nitro::audiobrowser::StyleDisplay>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "display"))),
69
- JSIConverter<std::optional<margelo::nitro::audiobrowser::ArtworkRendering>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "artworkRendering")))
80
+ JSIConverter<std::optional<margelo::nitro::audiobrowser::ArtworkRendering>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "artworkRendering"))),
81
+ JSIConverter<std::optional<margelo::nitro::audiobrowser::ImageShape>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "imageShape"))),
82
+ JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "accessorySymbol"))),
83
+ JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cardTint"))),
84
+ JSIConverter<std::optional<margelo::nitro::audiobrowser::CardImage>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cardImage")))
70
85
  );
71
86
  }
72
87
  static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::audiobrowser::TrackStyle& arg) {
73
88
  jsi::Object obj(runtime);
74
89
  obj.setProperty(runtime, PropNameIDCache::get(runtime, "display"), JSIConverter<std::optional<margelo::nitro::audiobrowser::StyleDisplay>>::toJSI(runtime, arg.display));
75
90
  obj.setProperty(runtime, PropNameIDCache::get(runtime, "artworkRendering"), JSIConverter<std::optional<margelo::nitro::audiobrowser::ArtworkRendering>>::toJSI(runtime, arg.artworkRendering));
91
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "imageShape"), JSIConverter<std::optional<margelo::nitro::audiobrowser::ImageShape>>::toJSI(runtime, arg.imageShape));
92
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "accessorySymbol"), JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.accessorySymbol));
93
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "cardTint"), JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.cardTint));
94
+ obj.setProperty(runtime, PropNameIDCache::get(runtime, "cardImage"), JSIConverter<std::optional<margelo::nitro::audiobrowser::CardImage>>::toJSI(runtime, arg.cardImage));
76
95
  return obj;
77
96
  }
78
97
  static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) {
@@ -85,6 +104,10 @@ namespace margelo::nitro {
85
104
  }
86
105
  if (!JSIConverter<std::optional<margelo::nitro::audiobrowser::StyleDisplay>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "display")))) return false;
87
106
  if (!JSIConverter<std::optional<margelo::nitro::audiobrowser::ArtworkRendering>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "artworkRendering")))) return false;
107
+ if (!JSIConverter<std::optional<margelo::nitro::audiobrowser::ImageShape>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "imageShape")))) return false;
108
+ if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "accessorySymbol")))) return false;
109
+ if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cardTint")))) return false;
110
+ if (!JSIConverter<std::optional<margelo::nitro::audiobrowser::CardImage>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "cardImage")))) return false;
88
111
  return true;
89
112
  }
90
113
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-audio-browser",
3
- "version": "0.2.0-next.12",
3
+ "version": "0.2.0-next.14",
4
4
  "description": "react-native-audio-browser is a react native package built with Nitro",
5
5
  "keywords": [
6
6
  "react-native",
@@ -31,6 +31,7 @@
31
31
  "ios/**/*.mm",
32
32
  "ios/**/*.cpp",
33
33
  "ios/**/*.swift",
34
+ "website/guide",
34
35
  "*.podspec",
35
36
  "README.md"
36
37
  ],
@@ -46,23 +47,27 @@
46
47
  "scripts": {
47
48
  "android:format": "yarn workspace example-native android:format",
48
49
  "android:format:check": "yarn workspace example-native android:format:check",
50
+ "android:gradle-stop": "yarn workspace example-native android:gradle-stop",
49
51
  "android:ide": "yarn workspace example-native android:ide",
50
52
  "android:logs": "react-native log-android",
51
53
  "android:test": "yarn workspace example-native android:test",
54
+ "android:types": "yarn workspace example-native android:types",
52
55
  "build": "yarn prepare",
53
56
  "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib",
54
57
  "codegen": "node codegen/index.js --logLevel=\"debug\" && node post-script.cjs && node codegen/strip-struct-equality.cjs && yarn build",
55
58
  "prepare": "bob build && yarn hooks:install",
56
59
  "hooks:install": "git config core.hooksPath .githooks || exit 0",
57
60
  "example": "yarn workspace example-native",
61
+ "ci:android": "yarn android:format:check && yarn android:types && yarn android:test",
58
62
  "ci:format": "oxfmt --check --ignore-path .gitignore --ignore-path .oxfmtignore .",
59
63
  "ci:format:ios": "swiftformat --lint ios",
64
+ "ci:ios": "yarn ci:format:ios && yarn ios:test",
60
65
  "ci:lint": "oxlint --type-aware src",
61
66
  "format": "oxfmt --write --ignore-path .gitignore --ignore-path .oxfmtignore .",
62
67
  "ios:format": "swiftformat ios",
63
68
  "ios:ide": "yarn workspace example-native ios:ide",
64
69
  "ios:rebuild": "yarn workspace example-native ios:rebuild",
65
- "ios:test": "swift test",
70
+ "ios:test": "swift test --disable-sandbox",
66
71
  "lint": "oxlint --fix",
67
72
  "release": "semantic-release",
68
73
  "test": "vitest run",
@@ -289,6 +289,7 @@ export function toNativeConfig(
289
289
  singleTrack: config.singleTrack,
290
290
  handleTrackLoad: config.handleTrackLoad,
291
291
  androidControllerOfflineError: config.androidControllerOfflineError,
292
+ viewMoreTitle: config.viewMoreTitle,
292
293
  carPlayLoadingTitle: config.carPlayLoadingTitle,
293
294
  resolveAlbumPath: config.resolveAlbumPath,
294
295
  formatNavigationError: config.formatNavigationError
@@ -118,7 +118,8 @@ export function usePolledProgress(updateInterval = 1000): Progress {
118
118
  }
119
119
 
120
120
  const stop = () => {
121
- clearTimeout(timer)
121
+ // Guarded: React Native types clearTimeout(handle: number) — no undefined.
122
+ if (timer !== undefined) clearTimeout(timer)
122
123
  timer = undefined
123
124
  unsubscribeState?.()
124
125
  unsubscribeState = undefined
@@ -14,8 +14,15 @@ export function getRate(): number {
14
14
 
15
15
  /**
16
16
  * Sets the playback rate.
17
+ *
18
+ * The rate is a speed multiplier, not a transport control — it is independent
19
+ * of play/pause, and setting it while paused never starts playback. Use
20
+ * {@link play} / {@link pause} for that.
21
+ *
17
22
  * @param rate - The playback rate to change to, where 0.5 would be half speed,
18
- * 1 would be regular speed, 2 would be double speed etc.
23
+ * 1 would be regular speed, 2 would be double speed etc. Must be greater than
24
+ * zero; platforms disagree on what a non-positive rate does (iOS ignores it,
25
+ * Android throws, web stalls the element), so treat it as unsupported.
19
26
  */
20
27
  export function setRate(rate: number): void {
21
28
  nativeBrowser.setRate(rate)
@@ -3,7 +3,12 @@ import { join } from 'node:path'
3
3
  import { describe, expect, it } from 'vitest'
4
4
 
5
5
  /**
6
- * Inheritance-completeness guard for the style declaration block (ADR 0011).
6
+ * Completeness guards for the style declaration block (ADR 0011) — the checks
7
+ * Nitro's `extends`-flattening took out of the type system, in two parts:
8
+ * every key must be *resolved* by each platform's `StyleResolver`, and every
9
+ * key must be *accounted for* by each platform's `InertStyleDiagnostic`.
10
+ *
11
+ * Part one — inheritance completeness.
7
12
  *
8
13
  * `SectionStyle extends TrackStyle` in the spec, but Nitro flattens `extends`
9
14
  * into independent structs on every platform — so nothing in any type system
@@ -137,3 +142,69 @@ for (const resolver of RESOLVERS) {
137
142
  })
138
143
  })
139
144
  }
145
+
146
+ /**
147
+ * Part two — diagnostic completeness.
148
+ *
149
+ * The block model traded compile-time invalid-combination errors for
150
+ * inertness, and the dev-mode `InertStyleDiagnostic` is the recovery. It is
151
+ * exposed to the same flattening problem as the resolvers, one level up: a
152
+ * key added to the spec has no type-system tie to the diagnostic's rule set,
153
+ * so a property could ship whose inert combinations nobody is warned about.
154
+ *
155
+ * Each platform therefore dispositions the whole vocabulary — every key is a
156
+ * `Property` with an inertness rule, `alwaysRenderable`, or `positional` —
157
+ * and this test reads those three declarations and asserts set equality with
158
+ * the spec, both directions: a new key must be dispositioned, and a key
159
+ * deleted from the spec must not linger in the rule set.
160
+ */
161
+ const DIAGNOSTICS = [
162
+ {
163
+ file: 'ios/Browser/InertStyleDiagnostic.swift',
164
+ // `case gridWrap`, up to the first member after the case list.
165
+ ruled: (source: string) =>
166
+ keysIn(
167
+ source,
168
+ /enum Property[\s\S]*?var isItemProperty/,
169
+ /^\s*case (\w+)$/gm
170
+ )
171
+ },
172
+ {
173
+ file: 'android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt',
174
+ // `GRID_WRAP("gridWrap", …)`, up to the `;` that ends the entry list.
175
+ ruled: (source: string) =>
176
+ keysIn(source, /enum class Property[\s\S]*?;/, /\w+\("(\w+)"/g)
177
+ }
178
+ ]
179
+
180
+ /** The capture group of every `pattern` match within the first `region`. */
181
+ function keysIn(source: string, region: RegExp, pattern: RegExp): string[] {
182
+ const found = region.exec(source)
183
+ expect(found, `region ${region} not found`).not.toBeNull()
184
+ return [...found![0].matchAll(pattern)].flatMap((match) =>
185
+ match[1] === undefined ? [] : [match[1]]
186
+ )
187
+ }
188
+
189
+ for (const diagnostic of DIAGNOSTICS) {
190
+ describe(diagnostic.file, () => {
191
+ // Comment-free, so prose naming a key doesn't pass for dispositioning it.
192
+ const source = stripComments(
193
+ readFileSync(join(ROOT, diagnostic.file), 'utf8')
194
+ )
195
+ // The two lists are spelled `alwaysRenderable`/`positional` in Swift and
196
+ // SCREAMING_CASE in Kotlin; both hold bare string literals.
197
+ const listed = [
198
+ ...source.matchAll(
199
+ /(?:alwaysRenderable|ALWAYS_RENDERABLE|positional|POSITIONAL)\s*=[^\n]*/gi
200
+ )
201
+ ].flatMap((match) =>
202
+ [...match[0].matchAll(/"(\w+)"/g)].map((key) => key[1])
203
+ )
204
+
205
+ it('dispositions every key of the block, and only those', () => {
206
+ const dispositioned = [...diagnostic.ruled(source), ...listed]
207
+ expect(new Set(dispositioned)).toEqual(new Set(sectionKeys))
208
+ })
209
+ })
210
+ }
@@ -14,7 +14,8 @@ import type {
14
14
  HandleTrackLoadCallback,
15
15
  MediaRequestConfig,
16
16
  SearchSourceCallback,
17
- TransformableRequestConfig
17
+ TransformableRequestConfig,
18
+ ViewMoreTitleCallback
18
19
  } from './browser'
19
20
  import type { ResolvedTrack } from './browser-nodes'
20
21
 
@@ -73,6 +74,9 @@ export interface NativeBrowserConfiguration {
73
74
  singleTrack?: boolean
74
75
  handleTrackLoad?: HandleTrackLoadCallback
75
76
  androidControllerOfflineError?: boolean
77
+ // Title of the "view more" row appended to a pathed section where the header
78
+ // can't be tapped (Android Auto). Absent = native's "View more" default.
79
+ viewMoreTitle?: ViewMoreTitleCallback
76
80
 
77
81
  // CarPlay options
78
82
  carPlayLoadingTitle?: string
@@ -25,6 +25,50 @@ export type StyleDisplay = 'list' | 'grid'
25
25
  */
26
26
  export type ArtworkRendering = 'original' | 'stencil'
27
27
 
28
+ /**
29
+ * The shape a grid tile's artwork is cropped to.
30
+ *
31
+ * - `'rounded-rectangle'` (default) — the standard tile crop.
32
+ * - `'circular'` — the standard presentation for *people* (artists, hosts,
33
+ * DJs). Shape encodes the kind of item, so mixed grids legitimately
34
+ * render circular artists next to rounded albums.
35
+ *
36
+ * Named after Apple's `imageShape` parameter (a platform passthrough), and
37
+ * deliberately without a grid prefix: every tile presentation that takes a
38
+ * shape reads the same property.
39
+ */
40
+ export type ImageShape = 'circular' | 'rounded-rectangle'
41
+
42
+ /**
43
+ * The kind of tile a grid renders.
44
+ *
45
+ * - `'plain'` (default) — artwork tiles with a title.
46
+ * - `'card'` — a larger card with title/subtitle and a
47
+ * {@link TrackStyle.cardTint} color; its image mode is
48
+ * {@link TrackStyle.cardImage}. Cards have no shape.
49
+ * - `'condensed'` — a denser cell carrying title + subtitle; takes
50
+ * {@link TrackStyle.imageShape} and {@link TrackStyle.accessorySymbol}
51
+ * like a plain tile.
52
+ *
53
+ * Element families are mutually exclusive within a container, so this is
54
+ * a container property — hence the enum, and the `grid` prefix.
55
+ */
56
+ export type GridTile = 'plain' | 'card' | 'condensed'
57
+
58
+ /**
59
+ * How a card tile uses its image.
60
+ *
61
+ * - `'normal'` (default) — image above the labels;
62
+ * {@link TrackStyle.cardTint} colors a gradient behind the labels.
63
+ * - `'background'` — the image fills the card full-height with the labels
64
+ * overlaid; `cardTint` becomes the color behind them. Selects the larger
65
+ * artwork target size.
66
+ *
67
+ * An enum rather than a boolean: it names the card's image mode and
68
+ * leaves room for future modes.
69
+ */
70
+ export type CardImage = 'normal' | 'background'
71
+
28
72
  /**
29
73
  * The style declaration block a {@link Track} may carry: *inherited item
30
74
  * properties* (resolved `track ?? section ?? page ?? default`, per
@@ -82,6 +126,58 @@ export interface TrackStyle {
82
126
  * @platform carplay
83
127
  */
84
128
  artworkRendering?: ArtworkRendering
129
+
130
+ /**
131
+ * Inherited (`track ?? section ?? page`): the shape a grid tile's
132
+ * artwork is cropped to — `'circular'` for people, the default
133
+ * `'rounded-rectangle'` for everything else. Shape follows the item
134
+ * wherever it travels: a circular artist stays circular in whatever
135
+ * grid renders it next. Inert where the presentation has no shape
136
+ * (list rows, pre-26 CarPlay, Android Auto).
137
+ *
138
+ * @default 'rounded-rectangle'
139
+ *
140
+ * @platform carplay
141
+ */
142
+ imageShape?: ImageShape
143
+
144
+ /**
145
+ * Inherited (`track ?? section ?? page`): an SF Symbol name drawn as
146
+ * the item's accessory wherever the current presentation supports one —
147
+ * the trailing edge of a list row, leading the title on an iOS 26 grid
148
+ * tile (placement is the platform's choice per surface). A resolved
149
+ * symbol replaces the derived accessory (the disclosure chevron a
150
+ * browsable row gets); `'none'` clears an inherited value, restoring
151
+ * the derived behavior — inheritance has no other escape, and no SF
152
+ * Symbol is named `none`, so the sentinel is collision-free. Inert off
153
+ * CarPlay.
154
+ *
155
+ * @platform carplay
156
+ */
157
+ accessorySymbol?: string
158
+
159
+ /**
160
+ * Inherited (`track ?? section ?? page`): the card treatment's tint
161
+ * color (hex, e.g. `'#1e3a8a'`) — a gradient behind the labels, or the
162
+ * color behind them when {@link TrackStyle.cardImage} is
163
+ * `'background'`. Card-prefixed: inert unless the container renders
164
+ * `gridTile: 'card'`.
165
+ *
166
+ * @platform carplay
167
+ */
168
+ cardTint?: string
169
+
170
+ /**
171
+ * Inherited (`track ?? section ?? page`): how a card tile uses its
172
+ * image — see {@link CardImage}. Uniform via the section value,
173
+ * mixable per card. Inert unless the container renders
174
+ * `gridTile: 'card'`.
175
+ *
176
+ * @default 'normal'
177
+ *
178
+ * @platform carplay
179
+ */
180
+ cardImage?: CardImage
85
181
  }
86
182
 
87
183
  /**
@@ -113,6 +209,20 @@ export interface SectionStyle extends TrackStyle {
113
209
  * @see https://developer.apple.com/documentation/carplay/cplistimagerowitem
114
210
  */
115
211
  gridWrap?: boolean
212
+
213
+ /**
214
+ * The kind of tile this container's grid renders — see
215
+ * {@link GridTile}. A container property (element families are
216
+ * container-homogeneous), resolved by scope override
217
+ * (`section ?? page`), never per item. Inert unless
218
+ * {@link TrackStyle.display} is `'grid'`, and on CarPlay before
219
+ * iOS 26 (where the treatment drops and the layout survives).
220
+ *
221
+ * @default 'plain'
222
+ *
223
+ * @platform carplay
224
+ */
225
+ gridTile?: GridTile
116
226
  }
117
227
 
118
228
  /**
@@ -183,11 +293,6 @@ export type TrackArtwork = string | ArtworkVariants
183
293
  export interface Section {
184
294
  /** Header text. Absent = headerless group. */
185
295
  title?: string
186
- /**
187
- * Secondary line for the section's navigation surface — e.g. the label
188
- * of the "view all" link a tile section gets on Android Auto.
189
- */
190
- subtitle?: string
191
296
  /**
192
297
  * Presentation, separated from content. Each surface renders the
193
298
  * declared layout's nearest supported form; on CarPlay
@@ -198,10 +303,20 @@ export interface Section {
198
303
  */
199
304
  style?: SectionStyle
200
305
  /**
201
- * Navigation target for the section header / "view all" surface. A
202
- * tile section's header tap (CarPlay) and appended "view all" link
203
- * (Android Auto) navigate here. Absent = a pure preview; the header is
204
- * not tappable.
306
+ * Navigation target for the section's "view all" affordance — the
307
+ * fuller page this section is a teaser for. Absent = a self-contained
308
+ * section: the header is a plain label, not a link.
309
+ *
310
+ * The surface picks the form (ADR 0010), and each carries the copy
311
+ * itself — a `path` never adds words to the section:
312
+ *
313
+ * - **CarPlay** — a tile section's row draws a disclosure chevron and
314
+ * the whole row navigates. A list section renders no affordance:
315
+ * CarPlay has no "see all" idiom for one, and an invented row would
316
+ * spend an item from the template's cap.
317
+ * - **Android Auto** — no header is tappable at any display, so the
318
+ * section gets an appended browsable row, titled by the global
319
+ * `viewMoreTitle` browser option ("View more" by default).
205
320
  */
206
321
  path?: string
207
322
  /** The section's tracks. */
@@ -931,6 +931,41 @@ export type BrowserConfiguration = {
931
931
  */
932
932
  androidControllerOfflineError?: boolean
933
933
 
934
+ /**
935
+ * Titles the "view more" row appended to a `Section` that carries a
936
+ * `Section.path` on surfaces whose section header cannot be tapped —
937
+ * Android Auto, today the only one.
938
+ *
939
+ * Leave it unset and the row is titled "View more" — the library's in-code
940
+ * default, the same arrangement `formatNavigationError` has. Set this to
941
+ * localize it: it is re-resolved once per content generation, so
942
+ * `invalidateAllContent()` after an in-app language switch picks up the new
943
+ * copy without reconfiguring the browser. That is why it is a callback rather
944
+ * than a plain string — an app whose locale is its own, not the system's,
945
+ * cannot express that with a value frozen at configure time.
946
+ *
947
+ * One string for every section by design — the row is a fallback for a
948
+ * surface that cannot draw a link in its header, so per-section wording would
949
+ * be ceremony most consumers never see rendered.
950
+ *
951
+ * For wording that varies by section — "View all 42 albums", "See every
952
+ * episode" — author the link yourself instead: append an ordinary browsable
953
+ * {@link Track} to the section's `children`, titled however you like, and
954
+ * leave `Section.path` unset so this row doesn't also appear. Since the
955
+ * row is Android-only, that branch is an ordinary platform check on the
956
+ * content you emit; CarPlay never sees the extra row.
957
+ *
958
+ * @default 'View more'
959
+ *
960
+ * @example
961
+ * ```typescript
962
+ * viewMoreTitle: () => t('View more')
963
+ * ```
964
+ *
965
+ * @platform android
966
+ */
967
+ viewMoreTitle?: ViewMoreTitleCallback
968
+
934
969
  /**
935
970
  * Title shown (as the list's centered empty state) on CarPlay screens whose
936
971
  * content is still loading — browse destinations while they resolve, and the
@@ -1093,3 +1128,6 @@ export type FormatNavigationErrorCallback = (
1093
1128
  * or `undefined` to do nothing. See `resolveAlbumPath`.
1094
1129
  */
1095
1130
  export type ResolveAlbumPathCallback = (track: Track) => string | undefined
1131
+
1132
+ /** Supplies the title of the appended "view more" row. See `viewMoreTitle`. */
1133
+ export type ViewMoreTitleCallback = () => string
@@ -160,6 +160,35 @@ describe('QueueManager', () => {
160
160
  expect(q.nextIndex()).toBeUndefined()
161
161
  })
162
162
 
163
+ // Filling a queue that had no current track: the order is shuffled with
164
+ // nothing to lead it, so the track that becomes current can land anywhere —
165
+ // last included, which reads as end-of-queue from the very first track.
166
+ it('leads the order with the start track when setTracks fills an empty queue', () => {
167
+ const fresh = new QueueManager()
168
+ fresh.setShuffleEnabled(true) // shuffle already on, queue still empty
169
+ fresh.setTracks(tracks(4))
170
+ fresh.currentIndex = 2 // the start index
171
+
172
+ // identity order [0,1,2,3] re-pinned on 2 → [2,1,0,3]
173
+ expect(fresh.previousIndex()).toBeUndefined()
174
+ expect(fresh.nextIndex()).toBe(1)
175
+ })
176
+
177
+ it('leads the order with the start track when insert fills an empty queue', () => {
178
+ const fresh = new QueueManager()
179
+ fresh.setShuffleEnabled(true)
180
+ const random = vi.spyOn(Math, 'random').mockReturnValue(0.999)
181
+ try {
182
+ fresh.insert(tracks(4)) // slotted in at the order's end → [0,1,2,3]
183
+ } finally {
184
+ random.mockRestore()
185
+ }
186
+ fresh.currentIndex = 3 // would sit LAST in the order without the re-pin
187
+
188
+ expect(fresh.nextIndex()).toBe(1)
189
+ expect(fresh.previousIndex()).toBeUndefined()
190
+ })
191
+
163
192
  it('keeps every track at its shuffle position across a move', () => {
164
193
  q.currentIndex = 1
165
194
  q.move(1, 3) // tracks [s0,s2,s3,s1]; order remapped to [0,3,1,2]
@@ -63,8 +63,22 @@ export class QueueManager {
63
63
  }
64
64
 
65
65
  set currentIndex(index: number | undefined) {
66
+ const hadNoCurrent = this._currentIndex === undefined
66
67
  this._lastIndex = this._currentIndex
67
68
  this._currentIndex = index
69
+
70
+ // A queue with no current track has an order that was shuffled with nothing
71
+ // to lead it — `setTracks` clears the index before regenerating, and
72
+ // `insert` drops new indices at random positions — so the track that then
73
+ // becomes current can sit anywhere, including last. That reads as
74
+ // end-of-queue immediately: `nextIndex` is undefined from the first track,
75
+ // so auto-advance fires onQueueEnded after one track. Re-pin at the moment
76
+ // the queue gains a current track. Safe as a setter hook here because the
77
+ // tracks are always in place before the index is assigned (both `setTracks`
78
+ // and `insert` run first).
79
+ if (hadNoCurrent && index !== undefined && this._shuffleEnabled) {
80
+ this.regenerateShuffleOrder()
81
+ }
68
82
  }
69
83
 
70
84
  get lastIndex(): number | undefined {
@@ -0,0 +1,114 @@
1
+ ---
2
+ description: 'Put your browse tree and playback on the Android Auto car screen: the automotive app descriptor, the manifest entries the library merges in, and "Hey Google, play…" voice search.'
3
+ ---
4
+
5
+ # Android Auto
6
+
7
+ ## Overview
8
+
9
+ Android Auto shows your [browse tree](/guide/basic-usage) on the car screen and plays through the same player as your app. Two systems drive it:
10
+
11
+ 1. **Browse tree** — Android Auto reads your content from a `MediaBrowserService`, which mirrors your browser routes. The library ships this service.
12
+ 2. **Voice search** — "Hey Google, play…" arrives as a `MEDIA_PLAY_FROM_SEARCH` intent, which the library routes to your `search` source.
13
+
14
+ Unlike CarPlay — which needs entitlements and scene delegates — Android Auto needs almost no app-side code. The library's own `AndroidManifest.xml` declares the media-browser service, the media session, the voice-search intent filter, and the foreground-service permissions, and those **merge into your app automatically**. You only have to declare your app as an automotive media app.
15
+
16
+ ## Setup
17
+
18
+ ### 1. Declare the automotive app descriptor
19
+
20
+ Add this `<meta-data>` inside the `<application>` tag of your app's `AndroidManifest.xml`:
21
+
22
+ ```xml
23
+ <meta-data
24
+ android:name="com.google.android.gms.car.application"
25
+ android:resource="@xml/automotive_app_desc" />
26
+ ```
27
+
28
+ ### 2. Add the descriptor resource
29
+
30
+ Create `android/app/src/main/res/xml/automotive_app_desc.xml`:
31
+
32
+ ```xml
33
+ <automotiveApp>
34
+ <uses name="media"/>
35
+ </automotiveApp>
36
+ ```
37
+
38
+ That's the whole setup. The `MediaBrowserService`, media session, `MEDIA_PLAY_FROM_SEARCH` intent filter, headless service for background playback, and `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_MEDIA_PLAYBACK` permissions all come from the library's merged manifest — you don't declare them yourself.
39
+
40
+ ## Voice search
41
+
42
+ Google Assistant requests ("play some jazz", "play the radishes") arrive as a `MEDIA_PLAY_FROM_SEARCH` intent. The library normalizes it into the same structured `SearchParams` your in-app and Siri searches use and calls your `search` source — no extra manifest setup, since the library declares the intent filter.
43
+
44
+ Android sends a subset of what iOS does: the query plus music focuses (genre / artist / album), always with `reference: 'unknown'`. See [Search](/guide/search) for how to resolve these requests and the cross-platform differences.
45
+
46
+ ## Browse display
47
+
48
+ Android Auto reads a page's layout from the **parent item** that opens it — before the page resolves. So the layout is declared in two places: on the parent item (a tab, or a row on another page) and on the page itself.
49
+
50
+ ```ts
51
+ // The parent item — a tab, or a row on another page. Android Auto
52
+ // reads the layout from here, before the page resolves.
53
+ {
54
+ title: 'Stations',
55
+ path: '/browse/stations',
56
+ style: { display: 'grid' }, // layout of the page this opens ('list' is the default)
57
+ }
58
+
59
+ // The page it opens — the ResolvedTrack served for '/browse/stations':
60
+ {
61
+ path: '/browse/stations',
62
+ title: 'Stations',
63
+ style: { display: 'grid' }, // the page-wide declaration
64
+ children: [ /* the tiles */ ]
65
+ }
66
+ ```
67
+
68
+ - **`style.display` on the parent item** — `'list'` (rows) or `'grid'` (tiles). Below the root this is the only layout signal Android Auto honors, and it is only sent when you declare it — leave it off and the page renders as a list. In dev mode the library warns when an all-grid page is opened from a parent item with no declared `display`. (A section's "view all" page needs no declaration of its own: it reuses the section's `style`.)
69
+ - **Sections** — declare `sections` on the page to group its tracks under headers; each [`Section`](/api/types/browser-nodes/#section)'s `title` renders as a header above its children. A section that carries a `path` also gets a browsable "view more" row appended after its children, navigating there. No header is tappable on Android Auto at any `display`, so the row is the only affordance the section's `path` has here; its title comes from the [`viewMoreTitle`](/api/types/browser/#viewmoretitle) browser option, defaulting to `"View more"` — one wording for every section. For per-section copy (`"View all 42 albums"`), leave `path` unset and append your own browsable `Track` to `children` instead, titled however you like; this page's content is Android-only anyway, so CarPlay never sees it.
70
+
71
+ ::: warning AAOS lays out the whole page from the parent item's `display`
72
+ On AOSP-based Android Automotive units — including mainstream cars' OEM skins — the parent item's `display` is the **only** below-root layout signal honored: the whole page renders as list or grid, uniformly. Per-section `display` differences (a single-line grid above a list, say) render on CarPlay and projected Android Auto, but an AAOS unit shows the page in the single declared layout. There is no way to describe a mixed page there — pick the layout that suits its dominant section, or split the content across pages. `gridWrap: false` also has no single-line container there: the grid wraps, showing more, never less.
73
+ :::
74
+
75
+ ```ts
76
+ sections: [
77
+ {
78
+ title: 'Live now',
79
+ children: [
80
+ { title: 'Morning Show', src: '…' },
81
+ { title: 'Afternoon Drive', src: '…' }
82
+ ]
83
+ },
84
+ { title: 'Up next', children: [{ title: 'Late Night Jazz', src: '…' }] }
85
+ ]
86
+ ```
87
+
88
+ ## Now Playing buttons
89
+
90
+ The buttons on the Now Playing screen come from the same
91
+ [`remoteButtonLayout`](/guide/remote-controls#button-layout-android) that drives the
92
+ notification — there's no car-specific option. Two behaviors are particular to
93
+ the car, though:
94
+
95
+ - **The head unit has the last word.** Slots are a preference. A unit with a
96
+ spare position may promote the first `overflow` entry onto the main row, so
97
+ a button you placed in overflow can still appear beside play/pause.
98
+ - **Fewer buttons, cleaner row.** If you want just
99
+ `jump-back │ play/pause │ jump-forward`, leave the rest out of the layout
100
+ entirely — they keep working from the steering wheel and headset regardless.
101
+
102
+ ## Testing
103
+
104
+ Test in a real car, or in Google's **Desktop Head Unit (DHU)** emulator:
105
+
106
+ 1. Install the **Android Auto Desktop Head Unit emulator** from the SDK Manager (_SDK Tools → Android Auto Desktop Head Unit emulator_).
107
+ 2. In the phone's **Android Auto** settings, enable Developer Mode (tap the version 10×), then **"Start head unit server"**.
108
+ 3. Connect the phone over USB and run the DHU.
109
+
110
+ See Google's [Test Android Auto apps with DHU](https://developer.android.com/training/cars/testing/dhu) for the full walkthrough. Your app appears under the media apps — browse the tree, exercise voice search, and verify artwork, grid/list styles, and section headers.
111
+
112
+ ## Reference
113
+
114
+ See the [example app](https://github.com/radio-garden/react-native-audio-browser/tree/main/apps/example-native/android) for a complete working manifest and descriptor.