react-native-audio-browser 0.2.0-next.17 → 0.2.0-next.19

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 (81) hide show
  1. package/android/build.gradle +2 -1
  2. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
  3. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
  4. package/android/src/main/java/com/audiobrowser/player/CrossOriginHeaderStripper.kt +77 -0
  5. package/android/src/main/java/com/audiobrowser/player/MediaFactory.kt +28 -10
  6. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
  7. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
  8. package/android/src/main/res/values/strings.xml +6 -21
  9. package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
  10. package/android/src/test/java/com/audiobrowser/player/CrossOriginHeaderStripperTest.kt +94 -0
  11. package/lib/commonjs/features/browser.js +8 -0
  12. package/lib/commonjs/features/browser.js.map +1 -1
  13. package/lib/commonjs/web/NativeAudioBrowser.js +163 -50
  14. package/lib/commonjs/web/NativeAudioBrowser.js.map +1 -1
  15. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js +37 -0
  16. package/lib/commonjs/web/TrackPlayer/LoadAttempt.js.map +1 -0
  17. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  18. package/lib/commonjs/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  19. package/lib/commonjs/web/TrackPlayer/Player.js +232 -39
  20. package/lib/commonjs/web/TrackPlayer/Player.js.map +1 -1
  21. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js +33 -12
  22. package/lib/commonjs/web/TrackPlayer/QueuePlayer.js.map +1 -1
  23. package/lib/commonjs/web/browser/BrowserManager.js +30 -12
  24. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  25. package/lib/commonjs/web/browser/SearchManager.js +16 -19
  26. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  27. package/lib/commonjs/web/http/RequestConfigBuilder.js +110 -27
  28. package/lib/commonjs/web/http/RequestConfigBuilder.js.map +1 -1
  29. package/lib/commonjs/web/http/followMediaRedirect.js +219 -0
  30. package/lib/commonjs/web/http/followMediaRedirect.js.map +1 -0
  31. package/lib/module/features/browser.js +8 -0
  32. package/lib/module/features/browser.js.map +1 -1
  33. package/lib/module/web/NativeAudioBrowser.js +163 -50
  34. package/lib/module/web/NativeAudioBrowser.js.map +1 -1
  35. package/lib/module/web/TrackPlayer/LoadAttempt.js +32 -0
  36. package/lib/module/web/TrackPlayer/LoadAttempt.js.map +1 -0
  37. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js +18 -2
  38. package/lib/module/web/TrackPlayer/PlaybackStateMachine.js.map +1 -1
  39. package/lib/module/web/TrackPlayer/Player.js +232 -39
  40. package/lib/module/web/TrackPlayer/Player.js.map +1 -1
  41. package/lib/module/web/TrackPlayer/QueuePlayer.js +33 -12
  42. package/lib/module/web/TrackPlayer/QueuePlayer.js.map +1 -1
  43. package/lib/module/web/browser/BrowserManager.js +30 -12
  44. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  45. package/lib/module/web/browser/SearchManager.js +16 -19
  46. package/lib/module/web/browser/SearchManager.js.map +1 -1
  47. package/lib/module/web/http/RequestConfigBuilder.js +110 -27
  48. package/lib/module/web/http/RequestConfigBuilder.js.map +1 -1
  49. package/lib/module/web/http/followMediaRedirect.js +215 -0
  50. package/lib/module/web/http/followMediaRedirect.js.map +1 -0
  51. package/lib/typescript/src/features/browser.d.ts +8 -0
  52. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  53. package/lib/typescript/src/types/browser.d.ts +13 -4
  54. package/lib/typescript/src/types/browser.d.ts.map +1 -1
  55. package/package.json +1 -1
  56. package/src/features/browser.ts +8 -0
  57. package/src/types/browser.ts +13 -4
  58. package/src/web/NativeAudioBrowser.playWhenReady.test.ts +232 -2
  59. package/src/web/NativeAudioBrowser.ts +193 -60
  60. package/src/web/TrackPlayer/LoadAttempt.ts +29 -0
  61. package/src/web/TrackPlayer/PlaybackStateMachine.test.ts +60 -13
  62. package/src/web/TrackPlayer/PlaybackStateMachine.ts +22 -6
  63. package/src/web/TrackPlayer/Player.dispatch.test.ts +5 -3
  64. package/src/web/TrackPlayer/Player.load.test.ts +137 -1
  65. package/src/web/TrackPlayer/Player.ts +256 -37
  66. package/src/web/TrackPlayer/QueuePlayer.ts +33 -14
  67. package/src/web/__tests__/lifecycle.test.ts +345 -0
  68. package/src/web/__tests__/lifecycleHarness.ts +351 -0
  69. package/src/web/__tests__/loadEdgeCases.test.ts +195 -0
  70. package/src/web/__tests__/loadSupersession.test.ts +175 -0
  71. package/src/web/__tests__/retry.test.ts +169 -0
  72. package/src/web/browser/BrowserManager.ts +32 -15
  73. package/src/web/browser/SearchManager.test.ts +75 -0
  74. package/src/web/browser/SearchManager.ts +23 -26
  75. package/src/web/browser/__tests__/artwork-resolution.test.ts +117 -0
  76. package/src/web/browser/__tests__/layer-resolver.test.ts +35 -0
  77. package/src/web/browser/__tests__/search-navigation.test.ts +76 -0
  78. package/src/web/http/RequestConfigBuilder.test.ts +241 -1
  79. package/src/web/http/RequestConfigBuilder.ts +140 -33
  80. package/src/web/http/followMediaRedirect.test.ts +289 -0
  81. package/src/web/http/followMediaRedirect.ts +253 -0
@@ -11,23 +11,28 @@ import androidx.media3.session.MediaSession
11
11
  import androidx.media3.session.SessionCommand
12
12
  import androidx.media3.session.SessionError
13
13
  import androidx.media3.session.SessionResult
14
+ import com.audiobrowser.browser.BrowserManager
14
15
  import com.audiobrowser.browser.handleTrackLoad
15
16
  import com.audiobrowser.browser.normalizedSections
16
17
  import com.audiobrowser.browser.styleResolvedSections
17
18
  import com.audiobrowser.browser.untitledSection
18
19
  import com.audiobrowser.extension.indexOfTappedTrack
19
20
  import com.audiobrowser.extension.toTrack
21
+ import com.audiobrowser.navigationErrorFor
20
22
  import com.audiobrowser.util.BrowserPathHelper
21
23
  import com.audiobrowser.util.RatingFavorites
22
24
  import com.audiobrowser.util.TrackFactory
23
25
  import com.google.common.collect.ImmutableList
24
26
  import com.google.common.util.concurrent.Futures
25
27
  import com.google.common.util.concurrent.ListenableFuture
28
+ import com.margelo.nitro.audiobrowser.FormattedNavigationError
26
29
  import com.margelo.nitro.audiobrowser.Gate
27
30
  import com.margelo.nitro.audiobrowser.GateEvent
28
31
  import com.margelo.nitro.audiobrowser.GateReason
29
32
  import com.margelo.nitro.audiobrowser.MediaReference
30
33
  import com.margelo.nitro.audiobrowser.NativeGateRequest
34
+ import com.margelo.nitro.audiobrowser.NavigationError
35
+ import com.margelo.nitro.audiobrowser.NavigationErrorType
31
36
  import com.margelo.nitro.audiobrowser.PlayerCapabilities
32
37
  import com.margelo.nitro.audiobrowser.RemoteButtonLayout
33
38
  import com.margelo.nitro.audiobrowser.SearchParams
@@ -62,6 +67,8 @@ class MediaSessionCallback(private val player: Player) :
62
67
  // threads (network observer, JS-triggered notifies) — guard all access with synchronized(this).
63
68
  private val parentIdSubscriptions =
64
69
  mutableMapOf<String, MutableSet<MediaSession.ControllerInfo>>()
70
+ private val failedSearch = FailedSearchSlot()
71
+ private val offlineSearches = OfflineSearchRegistry()
65
72
  private var mediaLibrarySession: MediaLibraryService.MediaLibrarySession? = null
66
73
 
67
74
  /**
@@ -73,7 +80,12 @@ class MediaSessionCallback(private val player: Player) :
73
80
  private set
74
81
 
75
82
  init {
76
- player.networkMonitor.observeOnline(scope) { _ -> notifySubscribedChildrenChanged() }
83
+ player.networkMonitor.observeOnline(scope) { online ->
84
+ notifySubscribedChildrenChanged()
85
+ // A browse level is re-queried through its subscription; a search has none, so its offline
86
+ // tile has to be taken off the screen by re-running the query.
87
+ if (online) scope.launch { reannounceOfflineSearches() }
88
+ }
77
89
  }
78
90
 
79
91
  /** Media3's legacy browse bridge asks for a whole list with pageSize [Int.MAX_VALUE]. */
@@ -85,42 +97,6 @@ class MediaSessionCallback(private val player: Player) :
85
97
  }
86
98
  }
87
99
 
88
- private fun createOfflineMediaItem(): MediaItem =
89
- createErrorMediaItem(
90
- mediaId = BrowserPathHelper.OFFLINE_PATH,
91
- title = player.context.getString(com.audiobrowser.R.string.audio_browser_offline_error),
92
- subtitle =
93
- player.context.getString(com.audiobrowser.R.string.audio_browser_offline_error_subtitle),
94
- )
95
-
96
- /**
97
- * Builds a non-browsable, non-playable [MediaItem] used to surface an error inside an Android
98
- * Auto / AAOS browse list. Rendered as a greyed-out, non-interactive tile, which is the only
99
- * side-effect-free way to communicate a browse failure to legacy controllers (Media3 drops
100
- * [LibraryResult.ofError] on the legacy browse bridge, leaving an empty "No items" screen).
101
- *
102
- * Tiles are the only in-browse error signal that exists. Verified on a head unit (2026-06): the
103
- * Android Auto browse list renders no error text for a transient [SessionError], for sticky
104
- * non-fatal replication, or for fatal replication (setLibraryErrorReplicationMode). Fatal
105
- * replication does surface the message on the playback screen, but at the cost of presenting the
106
- * session as STATE_ERROR with no actions, hiding the now-playing item and transport controls.
107
- */
108
- private fun createErrorMediaItem(mediaId: String, title: String, subtitle: String): MediaItem =
109
- MediaItem.Builder()
110
- .setMediaId(mediaId)
111
- .setMediaMetadata(
112
- MediaMetadata.Builder()
113
- .setTitle(title)
114
- .setSubtitle(subtitle)
115
- // Android Auto ignores these flags and drills into a tapped tile anyway; onGetChildren
116
- // returns an empty list for the sentinel paths, so it dead-ends at "No Items" rather
117
- // than stacking error pages.
118
- .setIsBrowsable(false)
119
- .setIsPlayable(false)
120
- .build()
121
- )
122
- .build()
123
-
124
100
  /**
125
101
  * Wraps a raw external-search query string into the structured [SearchParams] the gate request
126
102
  * carries. The car search surfaces only give a free-text query, so the other fields stay null —
@@ -143,28 +119,11 @@ class MediaSessionCallback(private val player: Player) :
143
119
  * gated browse/search level serves this single tile (see the gate checks in [onGetChildren] /
144
120
  * [onGetSearchResult]). Same shape as the error tiles — non-browsable, non-playable — and
145
121
  * deliberately NOT accompanied by a SessionError: a gate is deliberate app state, not a failure.
146
- * The message renders as the tile's subtitle (newlines collapse to spaces — list rows are
147
- * single-line).
148
122
  */
149
123
  private fun createGateMediaItem(gate: Gate): MediaItem =
150
124
  createErrorMediaItem(
151
- mediaId = BrowserPathHelper.GATE_PATH,
152
- title = gate.title,
153
- subtitle = gate.message?.replace('\n', ' ') ?: "",
154
- )
155
-
156
- /**
157
- * Builds a generic "something went wrong" error tile for a browse failure. The true offline case
158
- * is handled separately by the networkMonitor guard in [onGetChildren]; anything reaching the
159
- * catch block is an online-but-failed request (e.g. server down, bad status), so it must NOT be
160
- * labelled "no internet connection".
161
- */
162
- private fun createBrowseErrorMediaItem(): MediaItem =
163
- createErrorMediaItem(
164
- mediaId = BrowserPathHelper.ERROR_PATH,
165
- title = player.context.getString(com.audiobrowser.R.string.audio_browser_browse_error),
166
- subtitle =
167
- player.context.getString(com.audiobrowser.R.string.audio_browser_browse_error_subtitle),
125
+ BrowserPathHelper.GATE_PATH,
126
+ FormattedNavigationError(gate.title, gate.message),
168
127
  )
169
128
 
170
129
  fun updateMediaSession(
@@ -194,6 +153,18 @@ class MediaSessionCallback(private val player: Player) :
194
153
  return commandManager.buildConnectionResult(session)
195
154
  }
196
155
 
156
+ override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) {
157
+ Timber.Forest.d("MediaSession disconnect: ${controller.packageName}")
158
+ // A controller that leaves without unsubscribing (Android Auto's normal exit) would otherwise
159
+ // be notified — and held — forever.
160
+ offlineSearches.forget(controller)
161
+ synchronized(this) {
162
+ parentIdSubscriptions.values.forEach { it.remove(controller) }
163
+ parentIdSubscriptions.entries.removeAll { (_, browsers) -> browsers.isEmpty() }
164
+ }
165
+ super.onDisconnected(session, controller)
166
+ }
167
+
197
168
  override fun onCustomCommand(
198
169
  session: MediaSession,
199
170
  controller: MediaSession.ControllerInfo,
@@ -312,13 +283,11 @@ class MediaSessionCallback(private val player: Player) :
312
283
  }
313
284
  }
314
285
 
315
- // The error / offline tiles are dead-ends. Some controllers (e.g. Android Auto when online)
316
- // treat a non-browsable tile as tappable and subscribe to its mediaId anyway; returning the
317
- // error tile again here would push an endless stack of error pages. Return nothing instead.
318
- // For the offline tile, re-send the alert when still offline so a tap surfaces the
319
- // explanation again — but only then, since these paths are also re-queried by
320
- // notifyChildrenChanged (e.g. when connectivity returns) without any user action.
321
- if (parentId == BrowserPathHelper.OFFLINE_PATH || parentId == BrowserPathHelper.ERROR_PATH) {
286
+ // A drill-in on the error / offline / empty tile returns nothing (see isDeadEndPath). For
287
+ // the offline tile, re-send the alert when still offline so a tap surfaces the explanation
288
+ // again — but only then, since these paths are also re-queried by notifyChildrenChanged
289
+ // (e.g. when connectivity returns) without any user action.
290
+ if (BrowserPathHelper.isDeadEndPath(parentId)) {
322
291
  if (parentId == BrowserPathHelper.OFFLINE_PATH && !player.networkMonitor.isOnline.value) {
323
292
  sendBrowseError(session, browser, offline = true)
324
293
  }
@@ -330,7 +299,12 @@ class MediaSessionCallback(private val player: Player) :
330
299
  ) {
331
300
  Timber.w("Network offline - returning error message for: $parentId")
332
301
  sendBrowseError(session, browser, offline = true)
333
- return@future LibraryResult.ofItemList(ImmutableList.of(createOfflineMediaItem()), params)
302
+ return@future LibraryResult.ofItemList(
303
+ ImmutableList.of(
304
+ createOfflineMediaItem(audioBrowser.resolveFormattedError(offlineError(), parentId))
305
+ ),
306
+ params,
307
+ )
334
308
  }
335
309
 
336
310
  try {
@@ -352,7 +326,13 @@ class MediaSessionCallback(private val player: Player) :
352
326
  resolvedTrack.styleResolvedSections()
353
327
  ?: throw IllegalStateException("Expected browsed ResolvedTrack to have sections")
354
328
  browserManager.warnIfGridPageLacksPromise(parentId, sections)
355
- toMediaItems(sections)
329
+ // A page with no children resolved fine, so it raises no SessionError and no event —
330
+ // notifyChildrenChanged re-queries these paths without any user action. It serves the
331
+ // app's path-aware `empty-content` copy as one inert tile (ADR 0001) instead of
332
+ // Android Auto's bare "No items".
333
+ browseLevelItems(toMediaItems(sections)) {
334
+ audioBrowser.resolveFormattedError(emptyContentError(), parentId)
335
+ }
356
336
  }
357
337
 
358
338
  LibraryResult.ofItemList(ImmutableList.copyOf(children.paginate(page, pageSize)), params)
@@ -364,7 +344,14 @@ class MediaSessionCallback(private val player: Player) :
364
344
  // A tile rather than a bare ofError() — see createErrorMediaItem and
365
345
  // https://github.com/androidx/media/issues/2901
366
346
  sendBrowseError(session, browser, offline = false)
367
- LibraryResult.ofItemList(ImmutableList.of(createBrowseErrorMediaItem()), params)
347
+ LibraryResult.ofItemList(
348
+ ImmutableList.of(
349
+ createBrowseErrorMediaItem(
350
+ audioBrowser.resolveFormattedError(navigationErrorFor(e), parentId)
351
+ )
352
+ ),
353
+ params,
354
+ )
368
355
  }
369
356
  }
370
357
  }
@@ -444,14 +431,56 @@ class MediaSessionCallback(private val player: Player) :
444
431
  ): ListenableFuture<LibraryResult<MediaItem>> {
445
432
  Timber.Forest.d("onGetItem: ${browser.packageName}, mediaId = $mediaId")
446
433
  return scope.future {
447
- // Offline, error and root answer ahead of awaitBrowser: they need no browser. The gate
448
- // sentinel below still does, for gateDecision.
434
+ // Only the root answers ahead of awaitBrowser: it needs no browser. Every tile sentinel
435
+ // below does — for the formatter hop, and the gate one for gateDecision.
436
+ if (mediaId == BrowserPathHelper.ROOT_PATH) {
437
+ return@future LibraryResult.ofItem(
438
+ MediaItem.Builder()
439
+ .setMediaId(mediaId)
440
+ .setMediaMetadata(
441
+ MediaMetadata.Builder().setIsBrowsable(true).setIsPlayable(false).build()
442
+ )
443
+ .build(),
444
+ null,
445
+ )
446
+ }
447
+
449
448
  if (mediaId == BrowserPathHelper.OFFLINE_PATH) {
450
- return@future LibraryResult.ofItem(createOfflineMediaItem(), null)
449
+ return@future LibraryResult.ofItem(
450
+ createOfflineMediaItem(
451
+ player
452
+ .awaitBrowser()
453
+ .resolveFormattedError(offlineError(), BrowserPathHelper.OFFLINE_PATH)
454
+ ),
455
+ null,
456
+ )
451
457
  }
452
458
 
453
459
  if (mediaId == BrowserPathHelper.ERROR_PATH) {
454
- return@future LibraryResult.ofItem(createBrowseErrorMediaItem(), null)
460
+ // The exception that produced the tile is gone by now — the sentinel is all the controller
461
+ // hands back — so the copy formats for the sentinel under the generic failure code.
462
+ return@future LibraryResult.ofItem(
463
+ createBrowseErrorMediaItem(
464
+ player
465
+ .awaitBrowser()
466
+ .resolveFormattedError(browseFailureError(), BrowserPathHelper.ERROR_PATH)
467
+ ),
468
+ null,
469
+ )
470
+ }
471
+
472
+ if (mediaId == BrowserPathHelper.EMPTY_PATH) {
473
+ // A controller re-reading the tile it was served. The page's path is gone by now — the
474
+ // sentinel is all it hands back — so the copy formats for the sentinel and lands on the
475
+ // app's generic empty wording, or the default.
476
+ return@future LibraryResult.ofItem(
477
+ createEmptyMediaItem(
478
+ player
479
+ .awaitBrowser()
480
+ .resolveFormattedError(emptyContentError(), BrowserPathHelper.EMPTY_PATH)
481
+ ),
482
+ null,
483
+ )
455
484
  }
456
485
 
457
486
  if (mediaId == BrowserPathHelper.GATE_PATH) {
@@ -472,18 +501,6 @@ class MediaSessionCallback(private val player: Player) :
472
501
  return@future LibraryResult.ofItem(createGateMediaItem(outcome.chrome!!), null)
473
502
  }
474
503
 
475
- if (mediaId == BrowserPathHelper.ROOT_PATH) {
476
- return@future LibraryResult.ofItem(
477
- MediaItem.Builder()
478
- .setMediaId(mediaId)
479
- .setMediaMetadata(
480
- MediaMetadata.Builder().setIsBrowsable(true).setIsPlayable(false).build()
481
- )
482
- .build(),
483
- null,
484
- )
485
- }
486
-
487
504
  val browserManager = player.awaitBrowser().browserManager
488
505
 
489
506
  // Serve tracks from the track cache first (keyed by path and src). Besides avoiding an HTTP
@@ -628,32 +645,83 @@ class MediaSessionCallback(private val player: Player) :
628
645
  return@future LibraryResult.ofVoid()
629
646
  }
630
647
 
648
+ // Before the offline branch inside announceSearch: with no search source configured the
649
+ // answer is not-supported, online or not.
631
650
  if (!browserManager.config.hasSearch) {
632
651
  Timber.w("Search requested but no search source configured")
633
652
  return@future LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED)
634
653
  }
635
654
 
636
- try {
637
- // Caches the result set under the query, which onGetSearchResult reads back through
638
- // getCachedSearchResults.
639
- val searchResults = browserManager.search(query)
640
- // Count what onGetSearchResult will actually serve: disabled tracks
641
- // hide on Android Auto, so they must not inflate the announced count.
642
- val resultCount =
643
- searchResults.normalizedSections?.sumOf { section ->
644
- section.children.count { it.disabled != true }
645
- } ?: 0
646
-
647
- Timber.d("Search completed: $resultCount results for query '$query'")
655
+ announceSearch(session, browser, browserManager, query, params)
656
+ LibraryResult.ofVoid()
657
+ }
658
+ }
648
659
 
649
- session.notifySearchResultChanged(browser, query, resultCount, params)
660
+ /**
661
+ * Runs [query] and tells [browser] how many items [onGetSearchResult] will serve for it: the
662
+ * search's own hits, or the single tile standing in for them — a controller told zero never asks
663
+ * for items. Offline keeps the query for [reannounceOfflineSearches]; every other outcome drops
664
+ * it, so one controller holds at most its latest unanswered search.
665
+ */
666
+ private suspend fun announceSearch(
667
+ session: MediaLibraryService.MediaLibrarySession,
668
+ browser: MediaSession.ControllerInfo,
669
+ browserManager: BrowserManager,
670
+ query: String,
671
+ params: MediaLibraryService.LibraryParams?,
672
+ ) {
673
+ if (
674
+ !player.networkMonitor.isOnline.value && browserManager.config.androidControllerOfflineError
675
+ ) {
676
+ Timber.w("Network offline - announcing the offline tile for query: $query")
677
+ offlineSearches.record(browser, query, params)
678
+ session.notifySearchResultChanged(browser, query, 1, params)
679
+ return
680
+ }
681
+ offlineSearches.forget(browser)
682
+
683
+ try {
684
+ // Caches the result set under the query, which onGetSearchResult reads back through
685
+ // getCachedSearchResults.
686
+ val searchResults = browserManager.search(query)
687
+ failedSearch.clear()
688
+ val resultCount = announcedSearchCount(searchResults.normalizedSections)
689
+
690
+ Timber.d("Search announced $resultCount item(s) for query '$query'")
691
+
692
+ session.notifySearchResultChanged(browser, query, resultCount, params)
693
+ } catch (e: Exception) {
694
+ if (e is CancellationException && e !is TimeoutCancellationException) throw e
695
+ Timber.e(e, "Error during search for query: $query")
696
+ // A failed search announces its one item — the browse-error tile onGetSearchResult
697
+ // serves — rather than an ofError the browse list renders as nothing at all. Without
698
+ // the slot the failure is indistinguishable from an empty page by the time that call
699
+ // arrives, and a server error would read as the app's "no results" copy.
700
+ failedSearch.record(query, navigationErrorFor(e))
701
+ session.notifySearchResultChanged(browser, query, 1, params)
702
+ }
703
+ }
650
704
 
651
- LibraryResult.ofVoid()
652
- } catch (e: Exception) {
653
- if (e is CancellationException && e !is TimeoutCancellationException) throw e
654
- Timber.e(e, "Error during search for query: $query")
655
- LibraryResult.ofError(SessionError.ERROR_UNKNOWN)
705
+ /**
706
+ * Re-runs every search that was served the offline tile, now that the network is back. Announcing
707
+ * what the fresh search found — rather than a bare "one item" — keeps the announced count equal
708
+ * to what [onGetSearchResult] will then serve from the cache this fills.
709
+ */
710
+ private suspend fun reannounceOfflineSearches() {
711
+ val pending = offlineSearches.drain()
712
+ if (pending.isEmpty()) return
713
+ val session = mediaLibrarySession ?: return
714
+ val browserManager =
715
+ try {
716
+ player.awaitBrowser().browserManager
717
+ } catch (e: TimeoutCancellationException) {
718
+ Timber.w("Timed out waiting for browser - offline searches not re-announced")
719
+ return
656
720
  }
721
+
722
+ pending.forEach { search ->
723
+ Timber.d("Network back - re-running search: ${search.query}")
724
+ announceSearch(session, search.browser, browserManager, search.query, search.params)
657
725
  }
658
726
  }
659
727
 
@@ -691,18 +759,57 @@ class MediaSessionCallback(private val player: Player) :
691
759
  )
692
760
  }
693
761
 
694
- try {
695
- browserManager.getCachedSearchResults(query)?.let { tracks ->
696
- val mediaItems = toFlatMediaItems(tracks.toList())
762
+ // Checked before the failure slot, as in onGetChildren: a search that never reached the
763
+ // network is not a server failure, and connectivity returning is what fixes it.
764
+ if (
765
+ !player.networkMonitor.isOnline.value && browserManager.config.androidControllerOfflineError
766
+ ) {
767
+ Timber.w("Network offline - returning the offline tile for query: $query")
768
+ sendBrowseError(session, browser, offline = true)
769
+ return@future LibraryResult.ofItemList(
770
+ ImmutableList.of(
771
+ createOfflineMediaItem(
772
+ audioBrowser.resolveFormattedError(
773
+ offlineError(),
774
+ BrowserPathHelper.createSearchPath(query),
775
+ )
776
+ )
777
+ ),
778
+ params,
779
+ )
780
+ }
697
781
 
698
- val paginatedItems = mediaItems.paginate(page, pageSize)
699
- Timber.d("Returning ${paginatedItems.size} search results")
700
- LibraryResult.ofItemList(ImmutableList.copyOf(paginatedItems), params)
701
- }
702
- ?: run {
703
- Timber.w("No cached search results for query: $query")
704
- LibraryResult.ofItemList(ImmutableList.of(), params)
782
+ // The search itself failed, so this is a failure, not an empty page — same tile and same
783
+ // SessionError the browse catch sends, worded for the error its exception mapped to.
784
+ failedSearch.errorFor(query)?.let { error ->
785
+ sendBrowseError(session, browser, offline = false)
786
+ return@future LibraryResult.ofItemList(
787
+ ImmutableList.of(
788
+ createBrowseErrorMediaItem(
789
+ audioBrowser.resolveFormattedError(error, BrowserPathHelper.createSearchPath(query))
790
+ )
791
+ ),
792
+ params,
793
+ )
794
+ }
795
+
796
+ try {
797
+ val tracks = browserManager.getCachedSearchResults(query)
798
+ if (tracks == null) Timber.w("No cached search results for query: $query")
799
+ // A search with nothing servable — no hits, every hit disabled, or nothing cached under
800
+ // this query — serves the app's copy for the search path instead of the car's bare "No
801
+ // items" (ADR 0001). Something must always come back: onSearch announced one item.
802
+ val mediaItems =
803
+ browseLevelItems(toFlatMediaItems(tracks?.toList().orEmpty())) {
804
+ audioBrowser.resolveFormattedError(
805
+ emptyContentError(),
806
+ BrowserPathHelper.createSearchPath(query),
807
+ )
705
808
  }
809
+
810
+ val paginatedItems = mediaItems.paginate(page, pageSize)
811
+ Timber.d("Returning ${paginatedItems.size} search results")
812
+ LibraryResult.ofItemList(ImmutableList.copyOf(paginatedItems), params)
706
813
  } catch (e: Exception) {
707
814
  if (e is CancellationException && e !is TimeoutCancellationException) throw e
708
815
  Timber.e(e, "Error getting search results for query: $query")
@@ -890,3 +997,163 @@ class MediaSessionCallback(private val player: Player) :
890
997
  }
891
998
  }
892
999
  }
1000
+
1001
+ /**
1002
+ * Builds a non-browsable, non-playable [MediaItem] used to surface an error inside an Android Auto
1003
+ * / AAOS browse list. Rendered as a greyed-out, non-interactive tile, which is the only
1004
+ * side-effect-free way to communicate a browse failure to legacy controllers (Media3 drops
1005
+ * [LibraryResult.ofError] on the legacy browse bridge, leaving an empty "No items" screen).
1006
+ *
1007
+ * Tiles are the only in-browse error signal that exists. Verified on a head unit (2026-06): the
1008
+ * Android Auto browse list renders no error text for a transient [SessionError], for sticky
1009
+ * non-fatal replication, or for fatal replication (setLibraryErrorReplicationMode). Fatal
1010
+ * replication does surface the message on the playback screen, but at the cost of presenting the
1011
+ * session as STATE_ERROR with no actions, hiding the now-playing item and transport controls.
1012
+ */
1013
+ internal fun createErrorMediaItem(mediaId: String, title: String, subtitle: String): MediaItem =
1014
+ MediaItem.Builder()
1015
+ .setMediaId(mediaId)
1016
+ .setMediaMetadata(
1017
+ MediaMetadata.Builder()
1018
+ .setTitle(title)
1019
+ .setSubtitle(subtitle)
1020
+ // Android Auto ignores these flags and drills into a tapped tile anyway; onGetChildren
1021
+ // returns an empty list for the sentinel paths, so it dead-ends at "No Items" rather
1022
+ // than stacking error pages.
1023
+ .setIsBrowsable(false)
1024
+ .setIsPlayable(false)
1025
+ .build()
1026
+ )
1027
+ .build()
1028
+
1029
+ /**
1030
+ * The formatted form every tile is built from: the message renders as the subtitle, with newlines
1031
+ * collapsed to spaces — list rows are single-line.
1032
+ */
1033
+ private fun createErrorMediaItem(mediaId: String, formatted: FormattedNavigationError): MediaItem =
1034
+ createErrorMediaItem(
1035
+ mediaId = mediaId,
1036
+ title = formatted.title,
1037
+ subtitle = formatted.message?.replace('\n', ' ') ?: "",
1038
+ )
1039
+
1040
+ /**
1041
+ * Builds the empty tile from the app's formatted `empty-content` copy for the page's path, so an
1042
+ * empty Favorites tab and an empty search can be worded apart (ADR 0001). Same shape as the error
1043
+ * tiles — non-browsable, non-playable — and deliberately NOT accompanied by a SessionError: an
1044
+ * empty page is a successful resolve.
1045
+ */
1046
+ internal fun createEmptyMediaItem(formatted: FormattedNavigationError): MediaItem =
1047
+ createErrorMediaItem(BrowserPathHelper.EMPTY_PATH, formatted)
1048
+
1049
+ /**
1050
+ * Builds the offline tile from the app's formatted `network-error` copy for the path being served,
1051
+ * so one browse list never mixes the app's locale with the device's.
1052
+ */
1053
+ internal fun createOfflineMediaItem(formatted: FormattedNavigationError): MediaItem =
1054
+ createErrorMediaItem(BrowserPathHelper.OFFLINE_PATH, formatted)
1055
+
1056
+ /**
1057
+ * Builds the tile a failed browse or search serves, from the formatted copy for the error the
1058
+ * request's exception mapped to. The true offline case is handled separately by the networkMonitor
1059
+ * guards; anything reaching a catch block is an online-but-failed request (e.g. server down, bad
1060
+ * status), so it must NOT be labelled "no internet connection".
1061
+ */
1062
+ internal fun createBrowseErrorMediaItem(formatted: FormattedNavigationError): MediaItem =
1063
+ createErrorMediaItem(BrowserPathHelper.ERROR_PATH, formatted)
1064
+
1065
+ /**
1066
+ * What a non-root browse level or a search serves: its own items, or the lone empty tile when it
1067
+ * came back with none. [formatted] is a JS hop, so it runs only when there is nothing to show. The
1068
+ * root is not served through here — an empty tab list is a different condition.
1069
+ */
1070
+ internal suspend fun browseLevelItems(
1071
+ children: List<MediaItem>,
1072
+ formatted: suspend () -> FormattedNavigationError,
1073
+ ): List<MediaItem> = children.ifEmpty { listOf(createEmptyMediaItem(formatted())) }
1074
+
1075
+ /** The navigation error an empty page raises: no message of its own, the path carries the copy. */
1076
+ internal fun emptyContentError(): NavigationError =
1077
+ NavigationError(NavigationErrorType.EMPTY_CONTENT, "", null, null)
1078
+
1079
+ /** The navigation error a browse or search served while offline raises. */
1080
+ internal fun offlineError(): NavigationError =
1081
+ NavigationError(NavigationErrorType.NETWORK_ERROR, "", null, null)
1082
+
1083
+ /** The failure a re-read of the browse-error tile stands on, its own exception long gone. */
1084
+ internal fun browseFailureError(): NavigationError =
1085
+ NavigationError(NavigationErrorType.UNKNOWN_ERROR, "", null, null)
1086
+
1087
+ /**
1088
+ * The item count [MediaSessionCallback.onSearch] announces for a search that ran. Disabled tracks
1089
+ * hide on Android Auto (Track.disabled), so they never inflate it; and a search with nothing
1090
+ * servable announces one — the empty tile [MediaSessionCallback.onGetSearchResult] serves in their
1091
+ * place — because a controller told zero never asks for items. The gate and failure branches
1092
+ * announce their own single tile by the same rule.
1093
+ */
1094
+ internal fun announcedSearchCount(sections: List<Section>?): Int =
1095
+ (sections?.sumOf { section -> section.children.count { it.disabled != true } } ?: 0)
1096
+ .coerceAtLeast(1)
1097
+
1098
+ /**
1099
+ * The search each controller was last served the offline tile for. Media3 addresses a search result
1100
+ * by (controller, query) and keeps no record of its own, and a search has no subscription the way a
1101
+ * browse level does — so without this the offline tile stays on screen until the user searches
1102
+ * again. Only an offline-served query is kept: a search that ran found what it found, and one that
1103
+ * failed did so with a network.
1104
+ *
1105
+ * Written from the callback's IO scope and read from the connectivity observer, so every accessor
1106
+ * is synchronized.
1107
+ */
1108
+ internal class OfflineSearchRegistry {
1109
+ private val searches = mutableMapOf<MediaSession.ControllerInfo, OfflineSearch>()
1110
+
1111
+ @Synchronized
1112
+ fun record(
1113
+ browser: MediaSession.ControllerInfo,
1114
+ query: String,
1115
+ params: MediaLibraryService.LibraryParams?,
1116
+ ) {
1117
+ searches[browser] = OfflineSearch(browser, query, params)
1118
+ }
1119
+
1120
+ @Synchronized
1121
+ fun forget(browser: MediaSession.ControllerInfo) {
1122
+ searches.remove(browser)
1123
+ }
1124
+
1125
+ /** Takes every record: a re-run that finds the network gone again records its own. */
1126
+ @Synchronized
1127
+ fun drain(): List<OfflineSearch> = searches.values.toList().also { searches.clear() }
1128
+ }
1129
+
1130
+ /** One controller's unanswered search: everything announcing it again needs. */
1131
+ internal data class OfflineSearch(
1132
+ val browser: MediaSession.ControllerInfo,
1133
+ val query: String,
1134
+ val params: MediaLibraryService.LibraryParams?,
1135
+ )
1136
+
1137
+ /**
1138
+ * Remembers the query whose search threw, so [MediaSessionCallback.onGetSearchResult] serves the
1139
+ * browse-error tile instead of the empty one — by then the failure is otherwise indistinguishable
1140
+ * from a page that found nothing. Written and read from the callback's IO scope.
1141
+ *
1142
+ * One slot, matching BrowserManager's single-entry result cache: two failures in a row leave only
1143
+ * the later query recorded, so a late fetch of the earlier one serves the empty tile instead of the
1144
+ * error tile. Acceptable while only the most recent search's results are retrievable at all.
1145
+ */
1146
+ internal class FailedSearchSlot {
1147
+ @Volatile private var failed: Pair<String, NavigationError>? = null
1148
+
1149
+ fun record(query: String, error: NavigationError) {
1150
+ failed = query to error
1151
+ }
1152
+
1153
+ fun clear() {
1154
+ failed = null
1155
+ }
1156
+
1157
+ /** The error [query] failed with, or null when it is not the query that failed. */
1158
+ fun errorFor(query: String): NavigationError? = failed?.takeIf { it.first == query }?.second
1159
+ }
@@ -8,7 +8,7 @@ import java.net.URLEncoder
8
8
  * Utility for handling browser paths and contextual URLs in the media browser system.
9
9
  *
10
10
  * Handles two types of special paths:
11
- * 1. System paths (prefixed with `/__`): root, search, offline, error, and gate paths
11
+ * 1. System paths (prefixed with `/__`): root, search, offline, error, gate, and empty paths
12
12
  * 2. Contextual URLs: Embed parent context in track identifiers for Media3 integration
13
13
  *
14
14
  * Contextual URL format: `{parentPath}?__trackId={trackIdentity}&__index={childIndex}` (the
@@ -40,6 +40,9 @@ object BrowserPathHelper {
40
40
  /** Browse Gate placeholder media ID (subscription/login/region block) */
41
41
  const val GATE_PATH = "/__gate"
42
42
 
43
+ /** Empty-page placeholder media ID — a level that resolved with zero children (ADR 0001) */
44
+ const val EMPTY_PATH = "/__empty"
45
+
43
46
  // Query parameter name for contextual track identifiers
44
47
  private const val CONTEXTUAL_TRACK_PARAM = "__trackId"
45
48
 
@@ -51,6 +54,15 @@ object BrowserPathHelper {
51
54
  return path == ROOT_PATH || path.startsWith("$SEARCH_PATH_PREFIX?")
52
55
  }
53
56
 
57
+ /**
58
+ * The tile sentinels a drill-in dead-ends on: Android Auto ignores the tiles' non-browsable flag
59
+ * and subscribes anyway, so these serve no children rather than stacking another page.
60
+ * [GATE_PATH] is not one of them — it re-serves the gate tile so the message stays readable.
61
+ */
62
+ fun isDeadEndPath(path: String): Boolean {
63
+ return path == OFFLINE_PATH || path == ERROR_PATH || path == EMPTY_PATH
64
+ }
65
+
54
66
  /** Create a search path for a given query */
55
67
  fun createSearchPath(query: String): String {
56
68
  val encodedQuery = URLEncoder.encode(query, "UTF-8")