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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/README.md +10 -0
  2. package/android/build.gradle +20 -1
  3. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +5 -1
  4. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +8 -0
  5. package/android/src/main/java/com/audiobrowser/browser/InertStyleDiagnostic.kt +267 -0
  6. package/android/src/main/java/com/audiobrowser/browser/JsonModels.kt +27 -9
  7. package/android/src/main/java/com/audiobrowser/browser/StyleResolver.kt +11 -0
  8. package/android/src/main/java/com/audiobrowser/extension/ResolvedTrackExt.kt +11 -1
  9. package/android/src/main/java/com/audiobrowser/model/PlayerUpdateOptions.kt +1 -1
  10. package/android/src/main/java/com/audiobrowser/player/PlaybackStateStore.kt +17 -2
  11. package/android/src/main/java/com/audiobrowser/player/Player.kt +3 -0
  12. package/android/src/main/java/com/audiobrowser/player/ShuffleOrderLedBy.kt +30 -0
  13. package/android/src/main/java/com/audiobrowser/util/StyleWire.kt +73 -0
  14. package/android/src/main/java/com/audiobrowser/util/TrackFactory.kt +8 -1
  15. package/android/src/test/java/com/audiobrowser/TestFixtures.kt +49 -3
  16. package/android/src/test/java/com/audiobrowser/browser/BrowserManagerTest.kt +3 -2
  17. package/android/src/test/java/com/audiobrowser/browser/InertStyleDiagnosticTest.kt +277 -0
  18. package/android/src/test/java/com/audiobrowser/browser/JsonStyleTest.kt +56 -0
  19. package/android/src/test/java/com/audiobrowser/browser/SectionScopeTest.kt +4 -4
  20. package/android/src/test/java/com/audiobrowser/browser/StyleResolverTest.kt +77 -10
  21. package/android/src/test/java/com/audiobrowser/player/PlaybackStateStoreTrackJsonTest.kt +2 -2
  22. package/android/src/test/java/com/audiobrowser/player/ShuffleOrderLedByTest.kt +79 -0
  23. package/android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt +8 -8
  24. package/ios/Browser/BrowserManager.swift +106 -45
  25. package/ios/Browser/InertStyleDiagnostic.swift +279 -0
  26. package/ios/Browser/JsonModels.swift +81 -10
  27. package/ios/Browser/SectionPresentation.swift +30 -0
  28. package/ios/Browser/StyleResolver.swift +11 -0
  29. package/ios/Browser/TabBarEntries.swift +7 -1
  30. package/ios/CarPlay/CarPlayController.swift +139 -35
  31. package/ios/CarPlay/CarPlayListItemFactory.swift +99 -27
  32. package/ios/Extension/UIApplication+ActiveScene.swift +27 -0
  33. package/ios/Http/HttpClient.swift +8 -2
  34. package/ios/HybridAudioBrowser.swift +54 -11
  35. package/ios/Model/NitroTypeStubs.swift +60 -0
  36. package/ios/Player/MediaIntentCriteria.swift +13 -5
  37. package/ios/Player/MediaLoader.swift +33 -9
  38. package/ios/Player/NowPlayingUpdater.swift +53 -5
  39. package/ios/Player/PlaybackCoordinator.swift +11 -4
  40. package/ios/Player/PlaybackTimer.swift +8 -2
  41. package/ios/Player/QueueManager.swift +36 -10
  42. package/ios/Player/ShuffleOrder.swift +23 -0
  43. package/ios/Player/SleepTimerHandling.swift +13 -0
  44. package/ios/RemoteCommand/RemoteCommandController.swift +53 -18
  45. package/ios/Tests/InertStyleDiagnosticTests.swift +200 -0
  46. package/ios/Tests/JsonModelsTests.swift +76 -1
  47. package/ios/Tests/QueueManagerTests.swift +72 -6
  48. package/ios/Tests/RemoteCommandControllerTests.swift +16 -0
  49. package/ios/Tests/SectionPresentationTests.swift +29 -0
  50. package/ios/Tests/StyleResolverTests.swift +60 -0
  51. package/ios/Tests/Support/TestTypes.swift +7 -0
  52. package/ios/TrackPlayer.swift +57 -34
  53. package/ios/Util/NetworkMonitor.swift +43 -26
  54. package/ios/Util/SFSymbolRenderer.swift +24 -2
  55. package/ios/Util/SVGProcessor.swift +9 -2
  56. package/lib/commonjs/features/playback/progress.js +2 -1
  57. package/lib/commonjs/features/playback/progress.js.map +1 -1
  58. package/lib/commonjs/features/playback/rate.js +8 -1
  59. package/lib/commonjs/features/playback/rate.js.map +1 -1
  60. package/lib/commonjs/web/TrackPlayer/QueueManager.js +14 -0
  61. package/lib/commonjs/web/TrackPlayer/QueueManager.js.map +1 -1
  62. package/lib/module/features/playback/progress.js +2 -1
  63. package/lib/module/features/playback/progress.js.map +1 -1
  64. package/lib/module/features/playback/rate.js +8 -1
  65. package/lib/module/features/playback/rate.js.map +1 -1
  66. package/lib/module/web/TrackPlayer/QueueManager.js +14 -0
  67. package/lib/module/web/TrackPlayer/QueueManager.js.map +1 -1
  68. package/lib/typescript/src/features/playback/progress.d.ts.map +1 -1
  69. package/lib/typescript/src/features/playback/rate.d.ts +8 -1
  70. package/lib/typescript/src/features/playback/rate.d.ts.map +1 -1
  71. package/lib/typescript/src/types/browser-nodes.d.ts +104 -2
  72. package/lib/typescript/src/types/browser-nodes.d.ts.map +1 -1
  73. package/nitrogen/generated/android/c++/JArtworkRequestConfig.hpp +4 -0
  74. package/nitrogen/generated/android/c++/JBrowseResult.hpp +6 -0
  75. package/nitrogen/generated/android/c++/JCardImage.hpp +58 -0
  76. package/nitrogen/generated/android/c++/JFavoriteChangedEvent.hpp +4 -0
  77. package/nitrogen/generated/android/c++/JFormatNowPlayingParams.hpp +4 -0
  78. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_RequestConfig___Track.hpp +4 -0
  79. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_NowPlayingUpdate____FormatNowPlayingParams.hpp +4 -0
  80. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__optional_std__string____Track.hpp +4 -0
  81. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_RequestConfig_____Track.hpp +4 -0
  82. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_std__vector_Track______SearchParams.hpp +4 -0
  83. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__shared_ptr_Promise_void_____TrackLoadEvent.hpp +4 -0
  84. package/nitrogen/generated/android/c++/JFunc_std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError__std__shared_ptr_Promise_std__variant_ResolvedTrack__BrowseError_______BrowserSourceCallbackParam.hpp +6 -0
  85. package/nitrogen/generated/android/c++/JFunc_void_FavoriteChangedEvent.hpp +4 -0
  86. package/nitrogen/generated/android/c++/JFunc_void_PlaybackActiveTrackChangedEvent.hpp +4 -0
  87. package/nitrogen/generated/android/c++/JFunc_void_std__optional_ResolvedTrack_.hpp +6 -0
  88. package/nitrogen/generated/android/c++/JFunc_void_std__vector_Track_.hpp +4 -0
  89. package/nitrogen/generated/android/c++/JGridTile.hpp +61 -0
  90. package/nitrogen/generated/android/c++/JHybridAudioBrowserSpec.cpp +12 -0
  91. package/nitrogen/generated/android/c++/JImageShape.hpp +58 -0
  92. package/nitrogen/generated/android/c++/JMediaRequestConfig.hpp +4 -0
  93. package/nitrogen/generated/android/c++/JNativeBrowserConfiguration.hpp +6 -0
  94. package/nitrogen/generated/android/c++/JNativeRouteEntry.hpp +6 -0
  95. package/nitrogen/generated/android/c++/JNativeSetupPlayerOptions.hpp +4 -0
  96. package/nitrogen/generated/android/c++/JPlaybackActiveTrackChangedEvent.hpp +4 -0
  97. package/nitrogen/generated/android/c++/JResolvedTrack.hpp +6 -0
  98. package/nitrogen/generated/android/c++/JSection.hpp +6 -0
  99. package/nitrogen/generated/android/c++/JSectionStyle.hpp +30 -3
  100. package/nitrogen/generated/android/c++/JTrack.hpp +4 -0
  101. package/nitrogen/generated/android/c++/JTrackLoadEvent.hpp +4 -0
  102. package/nitrogen/generated/android/c++/JTrackStyle.hpp +24 -3
  103. package/nitrogen/generated/android/c++/JVariant_ResolvedTrack_BrowseError_Promise_BrowseResult_.hpp +6 -0
  104. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/CardImage.kt +23 -0
  105. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/GridTile.kt +24 -0
  106. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/ImageShape.kt +23 -0
  107. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/SectionStyle.kt +18 -3
  108. package/nitrogen/generated/android/kotlin/com/margelo/nitro/audiobrowser/TrackStyle.kt +15 -3
  109. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Bridge.hpp +54 -0
  110. package/nitrogen/generated/ios/AudioBrowser-Swift-Cxx-Umbrella.hpp +9 -0
  111. package/nitrogen/generated/ios/c++/HybridAudioBrowserSpecSwift.hpp +9 -0
  112. package/nitrogen/generated/ios/swift/CardImage.swift +40 -0
  113. package/nitrogen/generated/ios/swift/GridTile.swift +44 -0
  114. package/nitrogen/generated/ios/swift/ImageShape.swift +40 -0
  115. package/nitrogen/generated/ios/swift/SectionStyle.swift +70 -1
  116. package/nitrogen/generated/ios/swift/TrackStyle.swift +59 -1
  117. package/nitrogen/generated/shared/c++/CardImage.hpp +76 -0
  118. package/nitrogen/generated/shared/c++/GridTile.hpp +80 -0
  119. package/nitrogen/generated/shared/c++/ImageShape.hpp +76 -0
  120. package/nitrogen/generated/shared/c++/SectionStyle.hpp +32 -2
  121. package/nitrogen/generated/shared/c++/TrackStyle.hpp +25 -2
  122. package/package.json +7 -2
  123. package/src/features/playback/progress.ts +2 -1
  124. package/src/features/playback/rate.ts +8 -1
  125. package/src/style-resolution.test.ts +72 -1
  126. package/src/types/browser-nodes.ts +112 -2
  127. package/src/web/TrackPlayer/QueueManager.test.ts +29 -0
  128. package/src/web/TrackPlayer/QueueManager.ts +14 -0
  129. package/website/guide/android-auto.md +114 -0
  130. package/website/guide/android-certificates.md +409 -0
  131. package/website/guide/artwork.md +255 -0
  132. package/website/guide/audio-output.md +135 -0
  133. package/website/guide/automotive.md +86 -0
  134. package/website/guide/basic-usage.md +151 -0
  135. package/website/guide/battery.md +115 -0
  136. package/website/guide/browser.md +581 -0
  137. package/website/guide/carplay.md +620 -0
  138. package/website/guide/configuration.md +173 -0
  139. package/website/guide/equalizer.md +231 -0
  140. package/website/guide/errors.md +310 -0
  141. package/website/guide/favorites.md +154 -0
  142. package/website/guide/gate.md +143 -0
  143. package/website/guide/getting-started.md +46 -0
  144. package/website/guide/hooks.md +238 -0
  145. package/website/guide/live-streams.md +125 -0
  146. package/website/guide/metadata.md +239 -0
  147. package/website/guide/migrating-from-track-player.md +244 -0
  148. package/website/guide/native-callback-fetch.md +127 -0
  149. package/website/guide/network.md +86 -0
  150. package/website/guide/now-playing.md +175 -0
  151. package/website/guide/playback.md +328 -0
  152. package/website/guide/queue.md +300 -0
  153. package/website/guide/remote-controls.md +256 -0
  154. package/website/guide/search.md +255 -0
  155. package/website/guide/sleep-timer.md +268 -0
  156. package/website/guide/track.md +246 -0
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
@@ -757,7 +758,10 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
757
758
  return Promise.async(mainScope) {
758
759
  Timber.d("Searching for: $query")
759
760
  val searchResults = browserManager.search(query)
760
- searchResults.children ?: emptyArray()
761
+ // Flattened, not `children`: a resolved page carries its rows in
762
+ // `sections` (ADR 0010) and search builds exactly one untitled section,
763
+ // so `children` is always null here and this returned nothing at all.
764
+ searchResults.flattenedChildren?.toTypedArray() ?: emptyArray()
761
765
  }
762
766
  }
763
767
 
@@ -562,6 +562,10 @@ class BrowserManager {
562
562
  /** Clears all cached content. */
563
563
  fun clearContentCache() {
564
564
  contentCache.evictAll()
565
+ // The search slot is content too: a locale switch or a re-auth changes titles, src, artwork
566
+ // and availability, and nothing else ever evicts it, so a repeated query returned
567
+ // pre-invalidation results forever. (Matches the Swift twin's clearContentCache.)
568
+ lastSearch = null
565
569
  // Bump the layer generation so request/browse resolver thunks are re-resolved on the next
566
570
  // request (invalidateAllContent → clearContentCache is the documented re-resolve trigger).
567
571
  layerGeneration += 1
@@ -594,6 +598,10 @@ class BrowserManager {
594
598
  // section — ADR 0010), then generate contextual paths and transform artwork
595
599
  // URLs. The output never carries `children`.
596
600
  val sections = resolvedTrack.normalizedSections ?: return resolvedTrack
601
+ // Dev diagnostic (ADR 0011) — before the blocks are folded, while each level is
602
+ // still attributable. Every page passes here whatever its source, and only on a
603
+ // cache miss. No-op outside debug builds.
604
+ InertStyleDiagnostic.warn(path, resolvedTrack.style, sections)
597
605
  // The contextual index is the flat page position — children concatenated in
598
606
  // section order (ADR 0009/0010).
599
607
  var flatIndex = 0
@@ -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
@@ -106,19 +111,32 @@ private fun JsonObject.booleanField(key: String): Boolean? =
106
111
  */
107
112
  private fun JsonElement?.toTrackStyle(): TrackStyle? =
108
113
  (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)
114
+ TrackStyle(
115
+ display = obj.stringField("display").toStyleDisplay(),
116
+ artworkRendering = obj.stringField("artworkRendering").toArtworkRendering(),
117
+ imageShape = obj.stringField("imageShape").toImageShape(),
118
+ // 'none' is a legitimate accessory value (the inheritance escape);
119
+ // only emptiness collapses.
120
+ accessorySymbol = obj.stringField("accessorySymbol")?.takeIf { it.isNotEmpty() },
121
+ cardTint = obj.stringField("cardTint")?.takeIf { it.isNotEmpty() },
122
+ cardImage = obj.stringField("cardImage").toCardImage(),
123
+ )
124
+ .takeIf { it != EMPTY_TRACK_STYLE }
113
125
  }
114
126
 
115
127
  private fun JsonElement?.toSectionStyle(): SectionStyle? =
116
128
  (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)
129
+ SectionStyle(
130
+ display = obj.stringField("display").toStyleDisplay(),
131
+ artworkRendering = obj.stringField("artworkRendering").toArtworkRendering(),
132
+ imageShape = obj.stringField("imageShape").toImageShape(),
133
+ accessorySymbol = obj.stringField("accessorySymbol")?.takeIf { it.isNotEmpty() },
134
+ cardTint = obj.stringField("cardTint")?.takeIf { it.isNotEmpty() },
135
+ cardImage = obj.stringField("cardImage").toCardImage(),
136
+ gridWrap = obj.booleanField("gridWrap"),
137
+ gridTile = obj.stringField("gridTile").toGridTile(),
138
+ )
139
+ .takeIf { it != EMPTY_SECTION_STYLE }
122
140
  }
123
141
 
124
142
  private fun String?.toCarPlaySiriListButtonPosition(): CarPlaySiriListButtonPosition? {
@@ -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,
@@ -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
+ }