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
@@ -0,0 +1,79 @@
1
+ package com.audiobrowser.player
2
+
3
+ import androidx.media3.common.C
4
+ import org.junit.Assert.assertEquals
5
+ import org.junit.Assert.assertNotEquals
6
+ import org.junit.Assert.assertTrue
7
+ import org.junit.Test
8
+
9
+ /**
10
+ * Media3's `ShuffleOrder.cloneAndSet(insertionCount, startIndex)` discards the start index (the
11
+ * interface default clears and re-inserts from 0, and `DefaultShuffleOrder` does not override it),
12
+ * so `setMediaItems(items, startIndex, …)` leaves the starting track at a random shuffle position —
13
+ * last, for a 1-in-N share of queues, which reads as end-of-queue from the very first track.
14
+ */
15
+ class ShuffleOrderLedByTest {
16
+ @Test
17
+ fun `the start index leads the order`() {
18
+ for (start in 0 until 6) {
19
+ val order = shuffleOrderLedBy(startIndex = start, count = 6)
20
+ assertEquals("start index must be at shuffle position 0", start, order.firstIndex)
21
+ assertNotEquals(
22
+ "a queue of 6 must have somewhere to advance to from its start track",
23
+ C.INDEX_UNSET.toLong(),
24
+ order.getNextIndex(start).toLong(),
25
+ )
26
+ }
27
+ }
28
+
29
+ @Test
30
+ fun `every index appears exactly once`() {
31
+ val order = shuffleOrderLedBy(startIndex = 3, count = 8)
32
+ assertEquals(8, order.length)
33
+ val seen = mutableListOf<Int>()
34
+ var index = order.firstIndex
35
+ while (index != C.INDEX_UNSET) {
36
+ seen.add(index)
37
+ index = order.getNextIndex(index)
38
+ }
39
+ assertEquals((0 until 8).toList(), seen.sorted())
40
+ }
41
+
42
+ @Test
43
+ fun `a single-track queue leads with its only track and ends there`() {
44
+ val order = shuffleOrderLedBy(startIndex = 0, count = 1)
45
+ assertEquals(0, order.firstIndex)
46
+ assertEquals(C.INDEX_UNSET, order.getNextIndex(0))
47
+ }
48
+
49
+ @Test
50
+ fun `an out-of-range start index is clamped rather than throwing`() {
51
+ assertEquals(4, shuffleOrderLedBy(startIndex = 99, count = 5).firstIndex)
52
+ assertEquals(0, shuffleOrderLedBy(startIndex = -3, count = 5).firstIndex)
53
+ }
54
+
55
+ @Test
56
+ fun `an empty queue produces an empty order`() {
57
+ assertEquals(0, shuffleOrderLedBy(startIndex = 0, count = 0).length)
58
+ }
59
+
60
+ @Test
61
+ fun `the tail is actually shuffled`() {
62
+ // Not a strict guarantee of any single draw — but across many draws an
63
+ // unshuffled tail would be impossible to miss.
64
+ val tails =
65
+ (0 until 50)
66
+ .map { shuffleOrderLedBy(startIndex = 0, count = 8) }
67
+ .map { order ->
68
+ buildList {
69
+ var index = order.getNextIndex(order.firstIndex)
70
+ while (index != C.INDEX_UNSET) {
71
+ add(index)
72
+ index = order.getNextIndex(index)
73
+ }
74
+ }
75
+ }
76
+ .toSet()
77
+ assertTrue("50 draws produced one identical tail — the order is not shuffled", tails.size > 1)
78
+ }
79
+ }
@@ -2,10 +2,10 @@ package com.audiobrowser.util
2
2
 
3
3
  import androidx.media.utils.MediaConstants
4
4
  import com.audiobrowser.TestFixtures
5
+ import com.audiobrowser.TestFixtures.sectionStyle
6
+ import com.audiobrowser.TestFixtures.trackStyle
5
7
  import com.audiobrowser.browser.styleResolvedSections
6
- import com.margelo.nitro.audiobrowser.SectionStyle
7
8
  import com.margelo.nitro.audiobrowser.StyleDisplay
8
- import com.margelo.nitro.audiobrowser.TrackStyle
9
9
  import org.junit.Assert.assertEquals
10
10
  import org.junit.Assert.assertFalse
11
11
  import org.junit.Test
@@ -23,7 +23,7 @@ class MediaExtrasBuilderTest {
23
23
  private val track = TestFixtures.track()
24
24
 
25
25
  private fun sectionStyle(display: StyleDisplay?) =
26
- SectionStyle(display = display, artworkRendering = null, gridWrap = null)
26
+ sectionStyle(display = display, artworkRendering = null, gridWrap = null)
27
27
 
28
28
  @Test
29
29
  fun `owning section title stamps the group-title hint`() {
@@ -65,7 +65,7 @@ class MediaExtrasBuilderTest {
65
65
  @Test
66
66
  fun `a track's own display never sets its item hint - display is positional`() {
67
67
  val gridPromiseTrack =
68
- track.copy(style = TrackStyle(display = StyleDisplay.GRID, artworkRendering = null))
68
+ track.copy(style = trackStyle(display = StyleDisplay.GRID, artworkRendering = null))
69
69
  val section = TestFixtures.section(arrayOf(gridPromiseTrack), style = null)
70
70
  val extras = MediaExtrasBuilder.build(gridPromiseTrack, section)
71
71
  assertFalse(extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM))
@@ -75,7 +75,7 @@ class MediaExtrasBuilderTest {
75
75
  fun `a browsable track's declared display emits the parent-level promise`() {
76
76
  val handle =
77
77
  TestFixtures.browseTrack()
78
- .copy(style = TrackStyle(display = StyleDisplay.GRID, artworkRendering = null))
78
+ .copy(style = trackStyle(display = StyleDisplay.GRID, artworkRendering = null))
79
79
  val extras = MediaExtrasBuilder.build(handle)
80
80
  val expected = MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM
81
81
  assertEquals(
@@ -99,7 +99,7 @@ class MediaExtrasBuilderTest {
99
99
  @Test
100
100
  fun `a playable track's display is inert - playables open no page`() {
101
101
  val playable =
102
- track.copy(style = TrackStyle(display = StyleDisplay.GRID, artworkRendering = null))
102
+ track.copy(style = trackStyle(display = StyleDisplay.GRID, artworkRendering = null))
103
103
  val extras = MediaExtrasBuilder.build(playable)
104
104
  assertFalse(extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE))
105
105
  assertFalse(extras.containsKey(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE))
@@ -128,7 +128,7 @@ class MediaExtrasBuilderTest {
128
128
  arrayOf(track),
129
129
  title = "Popular",
130
130
  path = "/popular",
131
- style = SectionStyle(display = StyleDisplay.GRID, artworkRendering = null, gridWrap = false),
131
+ style = sectionStyle(display = StyleDisplay.GRID, artworkRendering = null, gridWrap = false),
132
132
  )
133
133
  val navigation = TrackFactory.navigationTrack(section)
134
134
  assertEquals(StyleDisplay.GRID, navigation.style?.display)
@@ -147,7 +147,7 @@ class MediaExtrasBuilderTest {
147
147
  // narrower unit test stays green while the page tier goes dark.
148
148
  val page =
149
149
  TestFixtures.resolvedTrack(
150
- style = SectionStyle(display = StyleDisplay.GRID, artworkRendering = null, gridWrap = null),
150
+ style = sectionStyle(display = StyleDisplay.GRID, artworkRendering = null, gridWrap = null),
151
151
  sections = arrayOf(TestFixtures.section(arrayOf(track))),
152
152
  )
153
153
 
@@ -67,8 +67,8 @@ final class BrowserManager {
67
67
  // LRU cache for resolved content - keyed by path
68
68
  private let contentCache = LRUCache<String, ResolvedTrack>(maxSize: 20)
69
69
 
70
- // Cache for search results
71
- private var lastSearchQuery: String?
70
+ // Most recent search, keyed by `searchCacheKey`.
71
+ private var lastSearchKey: String?
72
72
  private var lastSearchResults: [Track]?
73
73
 
74
74
  // Resolver caching: request/browse thunks resolve once per content generation.
@@ -171,8 +171,8 @@ final class BrowserManager {
171
171
  /// Optimistically reflects a local favorite toggle in the favorite set, keyed
172
172
  /// by the track's identity (id when non-blank, else src). The consumer
173
173
  /// reconciles `favoriteIds` to its canonical set on the next `setFavorites`;
174
- /// `isFavorite` gives the same answer before and after, so the now-playing
175
- /// heart stays responsive.
174
+ /// `hydrateFavorite` gives the same answer before and after, so the
175
+ /// now-playing heart stays responsive.
176
176
  func setFavorited(identity: String, favorited: Bool) {
177
177
  if favorited {
178
178
  favoriteIds.insert(identity)
@@ -188,13 +188,10 @@ final class BrowserManager {
188
188
  /// caller-set `favorited` wins — API/consumer-provided values are never
189
189
  /// overwritten (matches Android and web).
190
190
  func hydrateFavorite(_ track: Track) -> Track {
191
- guard favoriteEnabled, track.favorited == nil, track.identity != nil else { return track }
192
- return track.copying(favorited: isFavorite(track))
193
- }
194
-
195
- /// Whether the track's identity is in the favorite set.
196
- private func isFavorite(_ track: Track) -> Bool {
197
- track.identity.map(favoriteIds.contains) ?? false
191
+ // `identity` bridges a std::string out of the C++ track, so it is bound
192
+ // once here rather than computed again to answer "is it favorited".
193
+ guard favoriteEnabled, track.favorited == nil, let identity = track.identity else { return track }
194
+ return track.copying(favorited: favoriteIds.contains(identity))
198
195
  }
199
196
 
200
197
  /// Hydrates favorites on all tracks of a resolved page. Pages reach
@@ -221,12 +218,21 @@ final class BrowserManager {
221
218
  self.path = path
222
219
  content = nil // Clear for loading state
223
220
 
224
- let resolved = try await resolve(path)
221
+ do {
222
+ let resolved = try await resolve(path)
225
223
 
226
- // Only apply result if this is still the current navigation
227
- guard navigationId == currentNavigationId else { return }
224
+ // Only apply result if this is still the current navigation
225
+ guard navigationId == currentNavigationId else { return }
228
226
 
229
- content = resolved
227
+ content = resolved
228
+ } catch {
229
+ // A superseded navigation's failure is not the current page's failure.
230
+ // Every caller reports a throw unconditionally (and CarPlay presents it as
231
+ // a modal action sheet), so without this a slow /a that fails after a fast
232
+ // /b succeeded would show /a's error over /b's content.
233
+ guard navigationId == currentNavigationId else { return }
234
+ throw error
235
+ }
230
236
  }
231
237
 
232
238
  /// Resolves content for a path with optional caching.
@@ -248,7 +254,18 @@ final class BrowserManager {
248
254
  }
249
255
 
250
256
  /// Invalidates the content cache for a specific path.
251
- func invalidateContentCache(_ path: String) {
257
+ ///
258
+ /// Expects a *container* path. A contextual path (`…?__trackId=…`) addresses
259
+ /// a child, not a cache entry — entries are keyed by the stripped parent — so
260
+ /// passing one used to remove nothing at all, silently. Android `require`s the
261
+ /// same precondition; this throws so the misuse surfaces identically on both
262
+ /// platforms instead of no-opping on one.
263
+ func invalidateContentCache(_ path: String) throws {
264
+ guard !BrowserPathHelper.isContextual(path) else {
265
+ throw BrowserError.invalidConfiguration(
266
+ "invalidateContentCache() expects a container path, not a contextual path: \(path)",
267
+ )
268
+ }
252
269
  contentCache.remove(path)
253
270
  }
254
271
 
@@ -257,6 +274,11 @@ final class BrowserManager {
257
274
  func clearContentCache() {
258
275
  contentCache.clear()
259
276
  layerGeneration += 1
277
+ // The search slot is content too: a locale switch or a re-auth changes
278
+ // titles, src, artwork and availability, and nothing else ever evicts it,
279
+ // so a repeated query returned pre-invalidation results forever.
280
+ lastSearchKey = nil
281
+ lastSearchResults = nil
260
282
  }
261
283
 
262
284
  /// Refreshes the current path by invalidating cache and re-resolving.
@@ -269,12 +291,19 @@ final class BrowserManager {
269
291
 
270
292
  let currentPath = path
271
293
  contentCache.remove(currentPath)
272
- let resolved = try await resolve(currentPath, useCache: false)
273
294
 
274
- // Only apply result if this is still the current navigation
275
- guard navigationId == currentNavigationId else { return }
295
+ do {
296
+ let resolved = try await resolve(currentPath, useCache: false)
276
297
 
277
- content = resolved
298
+ // Only apply result if this is still the current navigation
299
+ guard navigationId == currentNavigationId else { return }
300
+
301
+ content = resolved
302
+ } catch {
303
+ // Superseded refreshes stay silent, for the same reason as `navigate`.
304
+ guard navigationId == currentNavigationId else { return }
305
+ throw error
306
+ }
278
307
  }
279
308
 
280
309
  private func resolveUncached(_ path: String) async throws -> ResolvedTrack {
@@ -295,6 +324,10 @@ final class BrowserManager {
295
324
  // untitled section — ADR 0010), then validate and transform. The output
296
325
  // never carries `children`.
297
326
  if let sections = resolvedTrack.normalizedSections {
327
+ // Dev diagnostic (ADR 0011) — before the blocks are folded, while each
328
+ // level is still attributable. Every page passes here whatever its
329
+ // source, and only on a cache miss. No-op outside debug builds.
330
+ InertStyleDiagnostic.warn(path: path, pageStyle: resolvedTrack.style, sections: sections)
298
331
  let transformed = try await transformSections(
299
332
  sections, parentPath: path, routeEntry: routeEntry?.0,
300
333
  )
@@ -574,16 +607,49 @@ final class BrowserManager {
574
607
  )
575
608
  }
576
609
 
577
- /// Structured search. Emits `q` plus any of `mode`/`genre`/`artist`/`album`/
578
- /// `title`/`playlist` that are set, mirroring Android's `executeSearchApiRequest`
579
- /// and the web stub. Cached by `query`.
610
+ /// The query params a structured search sends: `q` plus whichever filters are
611
+ /// set. Mirrors Android's `executeSearchApiRequest` and the web stub.
612
+ ///
613
+ /// Single source of truth for "what distinguishes one search from another" —
614
+ /// the request sends exactly this, and the cache keys on exactly this, so a
615
+ /// field added here can't be forgotten in one place and silently serve one
616
+ /// search's results for another.
617
+ private static func searchQueryParams(_ params: SearchParams) -> [String: String] {
618
+ var query: [String: String] = ["q": params.query]
619
+ if let mode = params.mode { query["mode"] = mode.stringValue }
620
+ if params.reference == .my { query["reference"] = "my" }
621
+ if let genre = params.genre { query["genre"] = genre }
622
+ if let artist = params.artist { query["artist"] = artist }
623
+ if let album = params.album { query["album"] = album }
624
+ if let title = params.title { query["title"] = title }
625
+ if let playlist = params.playlist { query["playlist"] = playlist }
626
+ return query
627
+ }
628
+
629
+ /// The cache identity of a search. Sorted so key order is stable, and
630
+ /// `\u{1}`-delimited so two different param sets can't serialize alike.
631
+ private static func searchCacheKey(_ params: SearchParams) -> String {
632
+ searchQueryParams(params)
633
+ .sorted { $0.key < $1.key }
634
+ .map { "\($0.key)=\($0.value)" }
635
+ .joined(separator: "\u{1}")
636
+ }
637
+
638
+ /// Structured search, cached by the full parameter set.
639
+ ///
640
+ /// Voice intents reuse `query` across different structures —
641
+ /// `MediaIntentCriteria` falls back to the structured value when there is no
642
+ /// spoken name, so "play jazz" (genre) and a text search for "jazz" arrive
643
+ /// with an identical `query` — hence `searchCacheKey` rather than `query`.
580
644
  func search(_ params: SearchParams) async throws -> ResolvedTrack {
581
645
  let query = params.query
646
+ let cacheKey = Self.searchCacheKey(params)
582
647
 
583
- // Check cache - re-hydrate favorites since they may have changed
584
- if query == lastSearchQuery, let results = lastSearchResults {
585
- let hydratedResults = results.map { hydrateFavorite($0) }
586
- return makeSearchResult(query: query, results: hydratedResults)
648
+ // Check cache - hydrate favorites on the way out, since they may have
649
+ // changed since the search ran (the slot holds the raw results for exactly
650
+ // this reason — see below).
651
+ if cacheKey == lastSearchKey, let results = lastSearchResults {
652
+ return makeSearchResult(query: query, results: results)
587
653
  }
588
654
 
589
655
  guard let routes = config.routes else {
@@ -608,18 +674,9 @@ final class BrowserManager {
608
674
  // Seed the search config's static path onto the base — a layer's static
609
675
  // path never applies (carried from the base; only a transform may change
610
676
  // it). Matches the web stub's fetchSearchResults and Android.
611
- var queryParams: [String: String] = ["q": query]
612
- if let mode = params.mode { queryParams["mode"] = mode.stringValue }
613
- if params.reference == .my { queryParams["reference"] = "my" }
614
- if let genre = params.genre { queryParams["genre"] = genre }
615
- if let artist = params.artist { queryParams["artist"] = artist }
616
- if let album = params.album { queryParams["album"] = album }
617
- if let title = params.title { queryParams["title"] = title }
618
- if let playlist = params.playlist { queryParams["playlist"] = playlist }
619
-
620
677
  let request = try await buildApiRequest(
621
678
  kind: nil, searchConfig, path: searchConfig.path ?? "", params: ["q": query],
622
- initialQuery: queryParams,
679
+ initialQuery: Self.searchQueryParams(params),
623
680
  )
624
681
  logger.debug("Searching via API: \(request.url)")
625
682
  let jsonTracks: [JsonTrack] = try await httpClient.requestJson(request, as: [JsonTrack].self)
@@ -628,14 +685,15 @@ final class BrowserManager {
628
685
  throw BrowserError.contentNotFound(path: Self.searchRoutePath)
629
686
  }
630
687
 
631
- // Hydrate favorites in results
632
- let hydratedResults = results.map { hydrateFavorite($0) }
633
-
634
- // Cache results
635
- lastSearchQuery = query
636
- lastSearchResults = hydratedResults
688
+ // Cache the RAW results, never the hydrated ones: hydration is one-shot
689
+ // (`hydrateFavorite` skips a track whose `favorited` is already set), so a
690
+ // hydrated slot would serve the favorites as they were at first search
691
+ // forever. The content cache keys on the same rule — it stores the
692
+ // unhydrated resolve and hydrates in `resolve()`.
693
+ lastSearchKey = cacheKey
694
+ lastSearchResults = results
637
695
 
638
- return makeSearchResult(query: query, results: hydratedResults)
696
+ return makeSearchResult(query: query, results: results)
639
697
  }
640
698
 
641
699
  /// Search and return playable tracks for "play «X»" voice intents, drilling
@@ -653,13 +711,16 @@ final class BrowserManager {
653
711
  }
654
712
  }
655
713
 
714
+ /// Wraps raw search results as a resolved page, hydrating favorites on the
715
+ /// way out — the single exit for `search`, so an unhydrated search page can't
716
+ /// be emitted (the same funnel `resolve()` gets from `hydrateChildren`).
656
717
  private func makeSearchResult(query: String, results: [Track]) -> ResolvedTrack {
657
718
  // Search results are a flat list; as a resolved page they are one
658
719
  // untitled section (ADR 0010).
659
720
  ResolvedTrack(
660
721
  path: BrowserPathHelper.createSearchPath(query),
661
722
  style: nil,
662
- sections: [.untitled(results)],
723
+ sections: [.untitled(results.map { hydrateFavorite($0) })],
663
724
  children: nil,
664
725
  carPlaySiriListButton: nil,
665
726
  id: nil,
@@ -0,0 +1,279 @@
1
+ import os.log
2
+
3
+ #if canImport(NitroModules)
4
+ import NitroModules
5
+ #endif
6
+
7
+ /// Dev diagnostic (ADR 0011): the declarations on a resolved page that can
8
+ /// never render.
9
+ ///
10
+ /// The block model deliberately gave up compile-time invalid-combination
11
+ /// errors — combinations are not invalid, they are *inert*, the way
12
+ /// `grid-template` on a non-grid element is inert in CSS. This is the
13
+ /// recovery: at page resolution every declaration its own block makes
14
+ /// unreadable is reported once, with the effective context and the fix.
15
+ ///
16
+ /// The line it draws, and never crosses: only **structural** inertness — a
17
+ /// declaration the resolved block itself renders unreadable, on every surface
18
+ /// (`gridWrap` outside a grid, `cardTint` with no card treatment in scope).
19
+ /// *Surface* inertness is never reported, because declarations are
20
+ /// aspirational: `imageShape` on Android Auto, or a card treatment on a
21
+ /// pre-26 CarPlay, is intended usage — the renderer drops what it can't draw.
22
+ ///
23
+ /// Runs where every page passes regardless of its source (static config,
24
+ /// route callback, or fetched browse JSON), and only on a cache miss, so a
25
+ /// mistake is reported once per resolution rather than once per serve.
26
+ ///
27
+ /// Debug builds only: `warn` is the whole seam, so a release build never even
28
+ /// walks the page (the trade is that a page served only to a Release build —
29
+ /// TestFlight, production browse JSON — reports nothing; reproduce it against
30
+ /// a debug build).
31
+ enum InertStyleDiagnostic {
32
+ /// Reports every inert declaration on a resolved page. The one gate — the
33
+ /// `#if` is here rather than at the call sites so callers read
34
+ /// unconditionally and the rule set stays free of build-configuration
35
+ /// knowledge.
36
+ static func warn(path: String, pageStyle: SectionStyle?, sections: [Section]) {
37
+ #if DEBUG
38
+ let logger = Logger(subsystem: "com.audiobrowser", category: "InertStyleDiagnostic")
39
+ for finding in findings(path: path, pageStyle: pageStyle, sections: sections) {
40
+ logger.warning("\(finding, privacy: .public)")
41
+ }
42
+ #endif
43
+ }
44
+
45
+ /// The declarations that carry a structural inertness condition.
46
+ ///
47
+ /// Two keys of the block are deliberately absent — `display` and
48
+ /// `artworkRendering`, dispositioned below instead.
49
+ private enum Property: String, CaseIterable {
50
+ case gridWrap
51
+ case gridTile
52
+ case imageShape
53
+ case accessorySymbol
54
+ case cardTint
55
+ case cardImage
56
+
57
+ /// Item properties inherit (`track ?? section ?? page`) and so may also
58
+ /// be declared per track; container properties resolve by scope override
59
+ /// (`section ?? page`) and never reach an item.
60
+ var isItemProperty: Bool {
61
+ switch self {
62
+ case .gridWrap, .gridTile: false
63
+ case .imageShape, .accessorySymbol, .cardTint, .cardImage: true
64
+ }
65
+ }
66
+
67
+ /// Whether a value of this property, resolved into `context`, has any
68
+ /// rendering to affect. Each case is the spec's own "inert unless" clause.
69
+ func isInert(in context: Context) -> Bool {
70
+ switch self {
71
+ case .gridWrap, .gridTile: !context.isGrid
72
+ case .imageShape: !context.isGrid || context.isCard
73
+ case .accessorySymbol: context.isCard
74
+ case .cardTint, .cardImage: !context.isCard
75
+ }
76
+ }
77
+
78
+ /// The rule the declaration falls foul of, stated positively.
79
+ var rule: String {
80
+ switch self {
81
+ case .gridWrap, .gridTile: "`\(rawValue)` renders only in a grid"
82
+ case .imageShape: "only plain and condensed tiles have a shape"
83
+ case .accessorySymbol: "card tiles have no accessory slot"
84
+ case .cardTint, .cardImage: "the card properties render only under `gridTile: 'card'`"
85
+ }
86
+ }
87
+
88
+ var hint: String {
89
+ switch self {
90
+ case .gridWrap, .gridTile:
91
+ "Declare `display: 'grid'` on the section or the page, or drop `\(rawValue)`."
92
+ case .imageShape:
93
+ "Render the items as plain or condensed grid tiles, or drop `imageShape`."
94
+ case .accessorySymbol:
95
+ "Use `gridTile: 'plain'` or `'condensed'`, or drop `accessorySymbol`."
96
+ case .cardTint, .cardImage:
97
+ "Declare `display: 'grid'` with `gridTile: 'card'`, or drop `\(rawValue)`."
98
+ }
99
+ }
100
+ }
101
+
102
+ /// Iterated per section; `allCases` is computed, so read it once.
103
+ private static let allProperties = Property.allCases
104
+
105
+ /// The rest of the block's keys, dispositioned rather than ruled on:
106
+ /// `artworkRendering` has no structural inertness condition (artwork renders
107
+ /// in every layout), and `display` is positional — inert only in the one
108
+ /// position that opens no page (see `playableDisplayFinding`). Declared here
109
+ /// so the spec-level completeness test can hold this file to the whole
110
+ /// vocabulary: every key of the block is a rule, always renderable, or
111
+ /// positional, and a newly added property is a decision rather than an
112
+ /// omission.
113
+ static let alwaysRenderable = ["artworkRendering"]
114
+ static let positional = ["display"]
115
+
116
+ /// A section's effective container context: the two container values every
117
+ /// inertness condition is stated against.
118
+ private struct Context {
119
+ let isGrid: Bool
120
+ let tile: GridTile
121
+
122
+ /// Folded through `StyleResolver` rather than re-deriving the scope
123
+ /// override: the diagnostic predicts what a renderer will do with these
124
+ /// declarations, so it has to resolve them the way the renderers do.
125
+ init(section: SectionStyle?, page: SectionStyle?) {
126
+ let folded = StyleResolver.sectionStyle(section: section, page: page)
127
+ isGrid = folded.display == .grid
128
+ tile = folded.gridTile ?? .plain
129
+ }
130
+
131
+ /// Whether the card treatment is what this container renders — the
132
+ /// condition the `card*` family is named after.
133
+ var isCard: Bool { isGrid && tile == .card }
134
+
135
+ /// The effective style, for the message — in the wire spelling the author
136
+ /// types (`stringValue` is the generated enum's own mapping). `gridTile`
137
+ /// is quoted only where a grid gives it meaning: naming it on a list would
138
+ /// be advice to fix the wrong property.
139
+ var described: String {
140
+ isGrid ? "display 'grid', gridTile '\(tile.stringValue)'" : "display 'list'"
141
+ }
142
+ }
143
+
144
+ /// Every inert declaration on a resolved page, as ready-to-log messages —
145
+ /// pure, so the whole rule set is testable off-device; the caller logs.
146
+ ///
147
+ /// - Parameters:
148
+ /// - path: the resolved path, named in every message.
149
+ /// - pageStyle: the page's own block (`ResolvedTrack.style`).
150
+ /// - sections: the page's normalized sections, blocks *unfolded* — the
151
+ /// diagnostic attributes each finding to the level that declared it, so
152
+ /// it must see the levels before `StyleResolver` merges them.
153
+ static func findings(path: String, pageStyle: SectionStyle?, sections: [Section]) -> [String] {
154
+ var findings: [String] = []
155
+ // A page-level declaration is judged across the whole page: it is live if
156
+ // it renders in any section it reaches, however many others ignore it.
157
+ var pageReached: Set<Property> = []
158
+ var pageRendered: Set<Property> = []
159
+
160
+ for (index, section) in sections.enumerated() {
161
+ let context = Context(section: section.style, page: pageStyle)
162
+ let sectionLabel = label(section, index: index)
163
+
164
+ for property in allProperties {
165
+ let inert = property.isInert(in: context)
166
+
167
+ if declared(property, in: section.style) {
168
+ if inert {
169
+ findings.append(
170
+ message(
171
+ property, path: path, owner: sectionLabel,
172
+ scope: "its effective style is \(context.described)",
173
+ ))
174
+ }
175
+ } else if declared(property, in: pageStyle) {
176
+ // The section takes the page's value (nothing closer overrode it).
177
+ pageReached.insert(property)
178
+ if !inert { pageRendered.insert(property) }
179
+ }
180
+
181
+ guard property.isItemProperty, inert else { continue }
182
+ let tracks = section.children.count(where: { declared(property, in: $0.style) })
183
+ if tracks > 0 {
184
+ findings.append(
185
+ message(
186
+ property, path: path, owner: "\(trackCount(tracks)) in \(sectionLabel)",
187
+ scope: "the section's effective style is \(context.described)",
188
+ ))
189
+ }
190
+ }
191
+
192
+ if let finding = playableDisplayFinding(section, path: path, label: sectionLabel) {
193
+ findings.append(finding)
194
+ }
195
+ }
196
+
197
+ for property in allProperties
198
+ where pageReached.contains(property) && !pageRendered.contains(property)
199
+ {
200
+ findings.append(
201
+ message(
202
+ property, path: path, owner: "the page block",
203
+ scope: "no section that inherits it renders one",
204
+ ))
205
+ }
206
+
207
+ return findings
208
+ }
209
+
210
+ /// `display` is positional, so it is never inert *in* a container — but on
211
+ /// a track that renders playable (`src` wins the rendering) it promises the
212
+ /// layout of a page that never opens.
213
+ private static func playableDisplayFinding(
214
+ _ section: Section, path: String, label: String,
215
+ ) -> String? {
216
+ let tracks = section.children.count(where: { $0.src != nil && $0.style?.display != nil })
217
+ guard tracks > 0 else { return nil }
218
+ return message(
219
+ path: path, owner: "\(trackCount(tracks)) in \(label)", key: "display",
220
+ reason: "`display` is the layout promise for the page a track opens, and a track "
221
+ + "rendered playable opens none",
222
+ hint: "Drop `display`, or give the track a `path` if it should be browsable.",
223
+ )
224
+ }
225
+
226
+ private static func message(
227
+ _ property: Property, path: String, owner: String, scope: String,
228
+ ) -> String {
229
+ message(
230
+ path: path, owner: owner, key: property.rawValue,
231
+ reason: "\(property.rule), but \(scope)", hint: property.hint,
232
+ )
233
+ }
234
+
235
+ /// The one message skeleton — every finding names the page, the declaration,
236
+ /// who declared it, why it can't render, and what to do about it.
237
+ private static func message(
238
+ path: String, owner: String, key: String, reason: String, hint: String,
239
+ ) -> String {
240
+ "Inert style declaration on page '\(path)': `style.\(key)` on \(owner) "
241
+ + "never renders — \(reason). \(hint)"
242
+ }
243
+
244
+ private static func label(_ section: Section, index: Int) -> String {
245
+ if let title = section.title, !title.isEmpty { return "section '\(title)'" }
246
+ return "section #\(index + 1)"
247
+ }
248
+
249
+ private static func trackCount(_ count: Int) -> String {
250
+ "\(count) track\(count == 1 ? "" : "s")"
251
+ }
252
+
253
+ // Nitro flattens the spec's `extends`, so the two blocks share no type —
254
+ // hence two lookups over the same key set rather than one protocol.
255
+
256
+ private static func declared(_ property: Property, in style: SectionStyle?) -> Bool {
257
+ guard let style else { return false }
258
+ switch property {
259
+ case .gridWrap: return style.gridWrap != nil
260
+ case .gridTile: return style.gridTile != nil
261
+ case .imageShape: return style.imageShape != nil
262
+ case .accessorySymbol: return style.accessorySymbol != nil
263
+ case .cardTint: return style.cardTint != nil
264
+ case .cardImage: return style.cardImage != nil
265
+ }
266
+ }
267
+
268
+ private static func declared(_ property: Property, in style: TrackStyle?) -> Bool {
269
+ guard let style else { return false }
270
+ switch property {
271
+ // Container properties have no Track slot to be declared in.
272
+ case .gridWrap, .gridTile: return false
273
+ case .imageShape: return style.imageShape != nil
274
+ case .accessorySymbol: return style.accessorySymbol != nil
275
+ case .cardTint: return style.cardTint != nil
276
+ case .cardImage: return style.cardImage != nil
277
+ }
278
+ }
279
+ }