react-native-audio-browser 0.2.0-next.16 → 0.2.0-next.18

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 (27) hide show
  1. package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +165 -107
  2. package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
  3. package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +409 -186
  4. package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
  5. package/android/src/main/res/values/strings.xml +6 -21
  6. package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
  7. package/lib/commonjs/features/browser.js +8 -0
  8. package/lib/commonjs/features/browser.js.map +1 -1
  9. package/lib/commonjs/web/browser/BrowserManager.js +6 -8
  10. package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
  11. package/lib/commonjs/web/browser/SearchManager.js +4 -7
  12. package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
  13. package/lib/module/features/browser.js +8 -0
  14. package/lib/module/features/browser.js.map +1 -1
  15. package/lib/module/web/browser/BrowserManager.js +6 -8
  16. package/lib/module/web/browser/BrowserManager.js.map +1 -1
  17. package/lib/module/web/browser/SearchManager.js +4 -7
  18. package/lib/module/web/browser/SearchManager.js.map +1 -1
  19. package/lib/typescript/package.json +1 -0
  20. package/lib/typescript/src/features/browser.d.ts +8 -0
  21. package/lib/typescript/src/features/browser.d.ts.map +1 -1
  22. package/package.json +2 -2
  23. package/src/features/browser.ts +8 -0
  24. package/src/web/browser/BrowserManager.ts +6 -11
  25. package/src/web/browser/SearchManager.test.ts +75 -0
  26. package/src/web/browser/SearchManager.ts +7 -10
  27. package/src/web/browser/__tests__/search-navigation.test.ts +76 -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
@@ -45,8 +50,12 @@ import kotlinx.coroutines.withContext
45
50
  import timber.log.Timber
46
51
 
47
52
  /**
48
- * MediaLibrarySession callback that handles all media session interactions. All logic is handled
49
- * directly by the AudioBrowser.
53
+ * Everything the MediaLibrarySession serves except transport: browse, search, item lookup, queue
54
+ * building, playback resumption, and the favorite and rating commands. Transport does not arrive
55
+ * here — it reaches the player through [InterceptingPlayer], and Assistant's play-from-search
56
+ * arrives as an intent handled in the service. The callbacks that answer through [scope] run on IO,
57
+ * so anything touching the ExoPlayer instance hops back to [Dispatchers.Main]; the rest answer
58
+ * synchronously on the Media3 application thread.
50
59
  */
51
60
  class MediaSessionCallback(private val player: Player) :
52
61
  MediaLibraryService.MediaLibrarySession.Callback {
@@ -58,6 +67,8 @@ class MediaSessionCallback(private val player: Player) :
58
67
  // threads (network observer, JS-triggered notifies) — guard all access with synchronized(this).
59
68
  private val parentIdSubscriptions =
60
69
  mutableMapOf<String, MutableSet<MediaSession.ControllerInfo>>()
70
+ private val failedSearch = FailedSearchSlot()
71
+ private val offlineSearches = OfflineSearchRegistry()
61
72
  private var mediaLibrarySession: MediaLibraryService.MediaLibrarySession? = null
62
73
 
63
74
  /**
@@ -69,14 +80,15 @@ class MediaSessionCallback(private val player: Player) :
69
80
  private set
70
81
 
71
82
  init {
72
- // Observe network state changes and notify subscribers
73
- 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
+ }
74
89
  }
75
90
 
76
- /**
77
- * Apply pagination to a list of items. If pageSize is 0 or MAX_VALUE (Android Auto default),
78
- * returns the full list.
79
- */
91
+ /** Media3's legacy browse bridge asks for a whole list with pageSize [Int.MAX_VALUE]. */
80
92
  private fun <T> List<T>.paginate(page: Int, pageSize: Int): List<T> {
81
93
  return if (pageSize in 1 until Int.MAX_VALUE) {
82
94
  this.drop(page * pageSize).take(pageSize)
@@ -85,45 +97,6 @@ class MediaSessionCallback(private val player: Player) :
85
97
  }
86
98
  }
87
99
 
88
- /** Creates an offline error MediaItem. */
89
- private fun createOfflineMediaItem(): MediaItem =
90
- createErrorMediaItem(
91
- mediaId = BrowserPathHelper.OFFLINE_PATH,
92
- title = player.context.getString(com.audiobrowser.R.string.audio_browser_offline_error),
93
- subtitle =
94
- player.context.getString(com.audiobrowser.R.string.audio_browser_offline_error_subtitle),
95
- )
96
-
97
- /**
98
- * Builds a non-browsable, non-playable [MediaItem] used to surface an error inside an Android
99
- * Auto / AAOS browse list. Rendered as a greyed-out, non-interactive tile, which is the only
100
- * side-effect-free way to communicate a browse failure to legacy controllers (Media3 drops
101
- * [LibraryResult.ofError] on the legacy browse bridge, leaving an empty "No items" screen).
102
- */
103
- private fun createErrorMediaItem(mediaId: String, title: String, subtitle: String): MediaItem =
104
- MediaItem.Builder()
105
- .setMediaId(mediaId)
106
- .setMediaMetadata(
107
- MediaMetadata.Builder()
108
- .setTitle(title)
109
- .setSubtitle(subtitle)
110
- // Non-browsable, non-playable. Android Auto ignores these flags and still drills into a
111
- // tapped tile, but onGetChildren returns an empty list for the sentinel paths so it's a
112
- // harmless "No Items" dead-end rather than an endless stack of error pages.
113
- .setIsBrowsable(false)
114
- .setIsPlayable(false)
115
- .build()
116
- )
117
- .build()
118
-
119
- /**
120
- * Builds the gate tile from a per-request chrome: while a gate is active, tabs stay visible but a
121
- * gated browse/search level serves this single tile (see the gate checks in [onGetChildren] /
122
- * [onGetSearchResult]). Same shape as the error tiles — non-browsable, non-playable — and
123
- * deliberately NOT accompanied by a SessionError: a gate is deliberate app state, not a failure.
124
- * The message renders as the tile's subtitle (newlines collapse to spaces — list rows are
125
- * single-line).
126
- */
127
100
  /**
128
101
  * Wraps a raw external-search query string into the structured [SearchParams] the gate request
129
102
  * carries. The car search surfaces only give a free-text query, so the other fields stay null —
@@ -141,25 +114,16 @@ class MediaSessionCallback(private val player: Player) :
141
114
  reference = MediaReference.UNKNOWN,
142
115
  )
143
116
 
144
- private fun createGateMediaItem(gate: Gate): MediaItem =
145
- createErrorMediaItem(
146
- mediaId = BrowserPathHelper.GATE_PATH,
147
- title = gate.title,
148
- subtitle = gate.message?.replace('\n', ' ') ?: "",
149
- )
150
-
151
117
  /**
152
- * Builds a generic "something went wrong" error tile for a browse failure. The true offline case
153
- * is handled separately by the [networkMonitor] guard in [onGetChildren]; anything reaching the
154
- * catch block is an online-but-failed request (e.g. server down, bad status), so it must NOT be
155
- * labelled "no internet connection".
118
+ * Builds the gate tile from a per-request chrome: while a gate is active, tabs stay visible but a
119
+ * gated browse/search level serves this single tile (see the gate checks in [onGetChildren] /
120
+ * [onGetSearchResult]). Same shape as the error tiles — non-browsable, non-playable — and
121
+ * deliberately NOT accompanied by a SessionError: a gate is deliberate app state, not a failure.
156
122
  */
157
- private fun createBrowseErrorMediaItem(): MediaItem =
123
+ private fun createGateMediaItem(gate: Gate): MediaItem =
158
124
  createErrorMediaItem(
159
- mediaId = BrowserPathHelper.ERROR_PATH,
160
- title = player.context.getString(com.audiobrowser.R.string.audio_browser_browse_error),
161
- subtitle =
162
- player.context.getString(com.audiobrowser.R.string.audio_browser_browse_error_subtitle),
125
+ BrowserPathHelper.GATE_PATH,
126
+ FormattedNavigationError(gate.title, gate.message),
163
127
  )
164
128
 
165
129
  fun updateMediaSession(
@@ -170,7 +134,6 @@ class MediaSessionCallback(private val player: Player) :
170
134
  forwardJumpInterval: Double,
171
135
  backwardJumpInterval: Double,
172
136
  ) {
173
- // Store as MediaLibrarySession for notifyChildrenChanged support
174
137
  this.mediaLibrarySession = mediaSession as? MediaLibraryService.MediaLibrarySession
175
138
  commandManager.updateMediaSession(
176
139
  mediaSession,
@@ -190,15 +153,26 @@ class MediaSessionCallback(private val player: Player) :
190
153
  return commandManager.buildConnectionResult(session)
191
154
  }
192
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
+
193
168
  override fun onCustomCommand(
194
169
  session: MediaSession,
195
170
  controller: MediaSession.ControllerInfo,
196
171
  command: SessionCommand,
197
172
  args: Bundle,
198
173
  ): ListenableFuture<SessionResult> {
199
- // Handle favorite button tap. Report an honest result: a success here makes
200
- // the controller flip its heart optimistically, so a no-op (no current
201
- // track) must say INVALID_STATE instead of success-then-revert.
174
+ // A success here makes the controller flip its heart optimistically, so a no-op (no current
175
+ // track) must report INVALID_STATE instead of success-then-revert.
202
176
  if (command.customAction == MediaSessionCommandManager.CUSTOM_ACTION_FAVORITE) {
203
177
  val applied = player.toggleActiveTrackFavorited()
204
178
  Timber.d("Favorite button tapped - toggle applied=$applied")
@@ -241,13 +215,6 @@ class MediaSessionCallback(private val player: Player) :
241
215
  browser: MediaSession.ControllerInfo,
242
216
  params: MediaLibraryService.LibraryParams?,
243
217
  ): ListenableFuture<LibraryResult<MediaItem>> {
244
- // Extract artwork size hint from root hints (e.g., from Android Auto)
245
- // TODO: Also consider these other root hints in the future:
246
- // - KEY_ROOT_HINT_MEDIA_HOST_VERSION
247
- // - KEY_ROOT_HINT_MEDIA_SESSION_API
248
- // - BROWSER_ROOT_HINTS_KEY_CUSTOM_BROWSER_ACTION_LIMIT
249
- // - BROWSER_ROOT_HINTS_KEY_ROOT_CHILDREN_LIMIT
250
- // - KEY_ROOT_HINT_MAX_QUEUE_ITEMS_WHILE_RESTRICTED
251
218
  params?.extras?.getInt(MediaConstants.EXTRAS_KEY_MEDIA_ART_SIZE_PIXELS, 0)?.let { size ->
252
219
  if (size > 0) {
253
220
  artworkSizeHintPixels = size
@@ -290,7 +257,6 @@ class MediaSessionCallback(private val player: Player) :
290
257
  "onGetChildren: {parentId: $parentId, page: $page, pageSize: $pageSize, isSpecialPath: ${BrowserPathHelper.isSpecialPath(parentId)} }"
291
258
  )
292
259
  return scope.future {
293
- // Wait for browser to be registered if it's not available yet
294
260
  val audioBrowser =
295
261
  player.awaitBrowser().also { Timber.d("Browser ready, proceeding with onGetChildren") }
296
262
  val browserManager = audioBrowser.browserManager
@@ -317,35 +283,35 @@ class MediaSessionCallback(private val player: Player) :
317
283
  }
318
284
  }
319
285
 
320
- // The error / offline tiles are dead-ends. Some controllers (e.g. Android Auto when online)
321
- // treat a non-browsable tile as tappable and subscribe to its mediaId anyway; returning the
322
- // error tile again here would push an endless stack of error pages. Return nothing instead.
323
- // For the offline tile, re-send the alert when still offline so a tap surfaces the
324
- // explanation again — but only then, since these paths are also re-queried by
325
- // notifyChildrenChanged (e.g. when connectivity returns) without any user action.
326
- 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)) {
327
291
  if (parentId == BrowserPathHelper.OFFLINE_PATH && !player.networkMonitor.isOnline.value) {
328
292
  sendBrowseError(session, browser, offline = true)
329
293
  }
330
294
  return@future LibraryResult.ofItemList(ImmutableList.of<MediaItem>(), params)
331
295
  }
332
296
 
333
- // Show offline error when offline:
334
297
  if (
335
298
  !player.networkMonitor.isOnline.value && browserManager.config.androidControllerOfflineError
336
299
  ) {
337
300
  Timber.w("Network offline - returning error message for: $parentId")
338
301
  sendBrowseError(session, browser, offline = true)
339
- 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
+ )
340
308
  }
341
309
 
342
310
  try {
343
311
  val children =
344
312
  if (parentId == BrowserPathHelper.ROOT_PATH) {
345
- // Return tabs as root children (limited to 4 for automotive platform compatibility)
346
- // TODO: Check what Android Auto does with empty tabs list - may need to return error?
347
- // Disabled tabs hide (Track.disabled) — filtered before the cap so
348
- // an unavailable tab never costs an available one its slot.
313
+ // Disabled tabs hide (Track.disabled) — filtered before the four-tab cap so an
314
+ // unavailable tab never costs an available one its slot.
349
315
  val tabs = browserManager.queryTabs().filterNot { it.disabled == true }
350
316
  if (tabs.size > 4) {
351
317
  Timber.w(
@@ -354,15 +320,19 @@ class MediaSessionCallback(private val player: Player) :
354
320
  }
355
321
  toFlatMediaItems(tabs.take(4))
356
322
  } else {
357
- // Resolve the specific path and flatten its sections to MediaItems
358
- // (paths are already set to contextual paths). Section styles come
359
- // page-folded (`section ?? page`, ADR 0011).
323
+ // Section styles arrive page-folded (`section ?? page`, ADR 0011).
360
324
  val resolvedTrack = browserManager.resolve(parentId)
361
325
  val sections =
362
326
  resolvedTrack.styleResolvedSections()
363
327
  ?: throw IllegalStateException("Expected browsed ResolvedTrack to have sections")
364
328
  browserManager.warnIfGridPageLacksPromise(parentId, sections)
365
- 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
+ }
366
336
  }
367
337
 
368
338
  LibraryResult.ofItemList(ImmutableList.copyOf(children.paginate(page, pageSize)), params)
@@ -371,18 +341,17 @@ class MediaSessionCallback(private val player: Player) :
371
341
  // error tile. awaitBrowser's TimeoutCancellationException IS a real failure, keep that.
372
342
  if (e is CancellationException && e !is TimeoutCancellationException) throw e
373
343
  Timber.e(e, "Error getting children for parentId: $parentId")
374
- // Surface an error tile instead of a bare ofError(): Media3 drops the error message on the
375
- // legacy browse bridge, which would otherwise leave an empty "No items" screen in Android
376
- // Auto. See https://github.com/androidx/media/issues/2901
377
- //
378
- // Verified on a head unit (2026-06): the AA browse list NEVER renders error text — not for
379
- // transient sendError, sticky non-fatal replication, or fatal replication
380
- // (setLibraryErrorReplicationMode). Fatal replication does show the message on the
381
- // playback screen, but presents the session as STATE_ERROR with no actions, hiding the
382
- // now-playing item and transport controls — not worth it. Tiles remain the only in-browse
383
- // signal we control.
344
+ // A tile rather than a bare ofError() — see createErrorMediaItem and
345
+ // https://github.com/androidx/media/issues/2901
384
346
  sendBrowseError(session, browser, offline = false)
385
- 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
+ )
386
355
  }
387
356
  }
388
357
  }
@@ -393,8 +362,8 @@ class MediaSessionCallback(private val player: Player) :
393
362
  * Media3 transiently attaches the error code/message to the platform session's playback state
394
363
  * without entering STATE_ERROR.
395
364
  *
396
- * NOTE: current Android Auto renders NOTHING for this (verified on a head unit — see
397
- * androidx/media#2901; the transient state is cleared microseconds after being set). It is kept
365
+ * NOTE: current Android Auto renders nothing for this — its transient state is cleared
366
+ * microseconds after being set (see [createErrorMediaItem], androidx/media#2901). It is kept
398
367
  * because it is the correct Media3-API error signal, costs nothing, and becomes user-visible if
399
368
  * Android Auto moves to consuming SessionError via the Media3 controller API.
400
369
  *
@@ -462,15 +431,56 @@ class MediaSessionCallback(private val player: Player) :
462
431
  ): ListenableFuture<LibraryResult<MediaItem>> {
463
432
  Timber.Forest.d("onGetItem: ${browser.packageName}, mediaId = $mediaId")
464
433
  return scope.future {
465
- // Handle special paths first (these don't need browser)
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
+
466
448
  if (mediaId == BrowserPathHelper.OFFLINE_PATH) {
467
- 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
+ )
468
457
  }
469
458
 
470
- // Return the error tile as a non-browsable item so tapping it can't re-browse into a
471
- // failing path (which would push another error page onto the stack).
472
459
  if (mediaId == BrowserPathHelper.ERROR_PATH) {
473
- 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
+ )
474
484
  }
475
485
 
476
486
  if (mediaId == BrowserPathHelper.GATE_PATH) {
@@ -491,19 +501,6 @@ class MediaSessionCallback(private val player: Player) :
491
501
  return@future LibraryResult.ofItem(createGateMediaItem(outcome.chrome!!), null)
492
502
  }
493
503
 
494
- if (mediaId == BrowserPathHelper.ROOT_PATH) {
495
- return@future LibraryResult.ofItem(
496
- MediaItem.Builder()
497
- .setMediaId(mediaId)
498
- .setMediaMetadata(
499
- MediaMetadata.Builder().setIsBrowsable(true).setIsPlayable(false).build()
500
- )
501
- .build(),
502
- null,
503
- )
504
- }
505
-
506
- // Wait for browser to be registered if it's not available yet
507
504
  val browserManager = player.awaitBrowser().browserManager
508
505
 
509
506
  // Serve tracks from the track cache first (keyed by path and src). Besides avoiding an HTTP
@@ -578,9 +575,6 @@ class MediaSessionCallback(private val player: Player) :
578
575
  }
579
576
 
580
577
  /**
581
- * Notifies all subscribed controllers to refresh their content. Called internally when network
582
- * state changes to refresh all subscribed paths.
583
- *
584
578
  * Safe to call from any thread: snapshots the subscribed paths and dispatches the session calls
585
579
  * to the main (Media3 application) thread.
586
580
  */
@@ -593,10 +587,8 @@ class MediaSessionCallback(private val player: Player) :
593
587
  }
594
588
 
595
589
  /**
596
- * Notifies external controllers that content at the given path has changed. Controllers
597
- * subscribed to this path will refresh their UI. Safe to call from any thread.
598
- *
599
- * @param path The path where content has changed
590
+ * Notifies external controllers that content at the given path has changed, so controllers
591
+ * subscribed to it refresh their UI. Safe to call from any thread.
600
592
  */
601
593
  fun notifyContentChanged(path: String) {
602
594
  Timber.d("Notifying content changed for path: $path")
@@ -614,10 +606,6 @@ class MediaSessionCallback(private val player: Player) :
614
606
  notifySubscribedChildrenChanged()
615
607
  }
616
608
 
617
- /**
618
- * Called when the browser becomes available after a cold start. Notifies all subscribed
619
- * controllers to refresh their content.
620
- */
621
609
  fun notifyBrowserReady() {
622
610
  Timber.d("Browser ready - notifying all subscribed paths")
623
611
  notifySubscribedChildrenChanged()
@@ -636,8 +624,6 @@ class MediaSessionCallback(private val player: Player) :
636
624
  ): ListenableFuture<LibraryResult<Void>> {
637
625
  Timber.d("onSearch: ${browser.packageName}, query = $query")
638
626
  return scope.future {
639
- // Wait for browser registration like onGetChildren does, so a cold-start voice search
640
- // doesn't fail before JS has configured the browser.
641
627
  val audioBrowser =
642
628
  try {
643
629
  player.awaitBrowser()
@@ -659,33 +645,83 @@ class MediaSessionCallback(private val player: Player) :
659
645
  return@future LibraryResult.ofVoid()
660
646
  }
661
647
 
662
- // Check if search is configured
648
+ // Before the offline branch inside announceSearch: with no search source configured the
649
+ // answer is not-supported, online or not.
663
650
  if (!browserManager.config.hasSearch) {
664
651
  Timber.w("Search requested but no search source configured")
665
652
  return@future LibraryResult.ofError(SessionError.ERROR_NOT_SUPPORTED)
666
653
  }
667
654
 
668
- try {
669
- // Execute search (automatically caches results at /__search?q=query)
670
- val searchResults = browserManager.search(query)
671
- // Count what onGetSearchResult will actually serve: disabled tracks
672
- // hide on Android Auto, so they must not inflate the announced count.
673
- val resultCount =
674
- searchResults.normalizedSections?.sumOf { section ->
675
- section.children.count { it.disabled != true }
676
- } ?: 0
677
-
678
- Timber.d("Search completed: $resultCount results for query '$query'")
655
+ announceSearch(session, browser, browserManager, query, params)
656
+ LibraryResult.ofVoid()
657
+ }
658
+ }
679
659
 
680
- // Notify Media3 of search results
681
- 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
+ }
682
704
 
683
- LibraryResult.ofVoid()
684
- } catch (e: Exception) {
685
- if (e is CancellationException && e !is TimeoutCancellationException) throw e
686
- Timber.e(e, "Error during search for query: $query")
687
- 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
688
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)
689
725
  }
690
726
  }
691
727
 
@@ -723,21 +759,57 @@ class MediaSessionCallback(private val player: Player) :
723
759
  )
724
760
  }
725
761
 
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
+ }
781
+
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
+
726
796
  try {
727
- // Get cached search results from BrowserManager
728
- browserManager.getCachedSearchResults(query)?.let { tracks ->
729
- // Through the shared browse conversion, so search results get the
730
- // same artwork routing as browse rows.
731
- val mediaItems = toFlatMediaItems(tracks.toList())
732
-
733
- val paginatedItems = mediaItems.paginate(page, pageSize)
734
- Timber.d("Returning ${paginatedItems.size} search results")
735
- LibraryResult.ofItemList(ImmutableList.copyOf(paginatedItems), params)
736
- }
737
- ?: run {
738
- Timber.w("No cached search results for query: $query")
739
- LibraryResult.ofItemList(ImmutableList.of(), params)
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
+ )
740
808
  }
809
+
810
+ val paginatedItems = mediaItems.paginate(page, pageSize)
811
+ Timber.d("Returning ${paginatedItems.size} search results")
812
+ LibraryResult.ofItemList(ImmutableList.copyOf(paginatedItems), params)
741
813
  } catch (e: Exception) {
742
814
  if (e is CancellationException && e !is TimeoutCancellationException) throw e
743
815
  Timber.e(e, "Error getting search results for query: $query")
@@ -766,8 +838,9 @@ class MediaSessionCallback(private val player: Player) :
766
838
  return scope.future {
767
839
  val audioBrowser = player.awaitBrowser()
768
840
 
769
- // Helper: returns the current player state unchanged so Media3 doesn't modify playback.
770
- // ExoPlayer is main-thread confined; this future runs on IO, so hop to Main for the reads.
841
+ // The "leave playback alone" answer for handleTrackLoad: returning the current state
842
+ // unchanged is how Media3 is told to do nothing. ExoPlayer is main-thread confined and this
843
+ // future runs on IO, so the reads hop to Main.
771
844
  suspend fun currentPlayerState(): MediaSession.MediaItemsWithStartPosition =
772
845
  withContext(Dispatchers.Main) {
773
846
  val currentItems = player.tracks.map { TrackFactory.toMedia3(it) }
@@ -788,14 +861,12 @@ class MediaSessionCallback(private val player: Player) :
788
861
  ?.takeIf { it.requestMetadata.searchQuery == null }
789
862
  ?.let { browserManager.contextualPathFor(it.mediaId) }
790
863
 
791
- // Check if this is a single item from the current queue source
792
864
  if (singleContextualPath != null) {
793
865
  val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
794
866
  val trackId = BrowserPathHelper.extractTrackId(singleContextualPath)
795
867
 
796
- // Check if queue already came from this parent path - just skip to the
797
- // tapped surface (exact path first, identity for index-less paths —
798
- // see indexOfTappedTrack)
868
+ // A tap inside the queue's own source list skips rather than rebuilds (exact path first,
869
+ // identity for index-less paths — see indexOfTappedTrack).
799
870
  if (trackId != null && parentPath == player.queueSourcePath) {
800
871
  val queueTracks = withContext(Dispatchers.Main) { player.tracks }
801
872
  val index = queueTracks.indexOfTappedTrack(singleContextualPath, trackId)
@@ -809,7 +880,6 @@ class MediaSessionCallback(private val player: Player) :
809
880
  index.toDouble(),
810
881
  ::currentPlayerState,
811
882
  ) {
812
- // Return the existing queue items with the new start index
813
883
  val existingItems = queueTracks.map { TrackFactory.toMedia3(it) }
814
884
  MediaSession.MediaItemsWithStartPosition(existingItems, index, startPositionMs)
815
885
  }
@@ -830,8 +900,8 @@ class MediaSessionCallback(private val player: Player) :
830
900
  result.startIndex.toDouble(),
831
901
  ::currentPlayerState,
832
902
  ) {
833
- // If this was a contextual path expansion, track the source path (only for default
834
- // behavior)
903
+ // Inside handleTrackLoad's default branch: a consumer that handles the load itself owns
904
+ // the queue, so queueSourcePath must not claim this path on its behalf.
835
905
  if (singleContextualPath != null) {
836
906
  val parentPath = BrowserPathHelper.stripTrackId(singleContextualPath)
837
907
  withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
@@ -849,8 +919,6 @@ class MediaSessionCallback(private val player: Player) :
849
919
  * using the browse callback. This enables seamless resumption after app restart with the same
850
920
  * player settings (repeat mode, shuffle, playback speed).
851
921
  *
852
- * @param mediaSession The media session
853
- * @param controller The controller requesting resumption
854
922
  * @param isForPlayback True if this should start playback; false if just gathering info for the
855
923
  * boot-time resumption notification (no network; local metadata only).
856
924
  * @see https://developer.android.com/media/media3/session/background-playback#resumption
@@ -881,9 +949,8 @@ class MediaSessionCallback(private val player: Player) :
881
949
  throw IllegalStateException("Persisted track is disabled")
882
950
  }
883
951
 
884
- // isForPlayback == false is the device-boot-time notification case: network may be
885
- // unavailable and we must not start playback. Return just the locally-stored track with its
886
- // already-local metadata; skip the settings restore and network queue expansion below.
952
+ // Info-gathering returns before the settings restore and the queue expansion below: both
953
+ // touch the live player or the network, and neither is permitted here.
887
954
  if (!isForPlayback) {
888
955
  Timber.d("Info-gathering resumption (boot-time); returning stored track without expansion")
889
956
  return@future MediaSession.MediaItemsWithStartPosition(
@@ -899,10 +966,8 @@ class MediaSessionCallback(private val player: Player) :
899
966
  val path = state.track.path
900
967
  Timber.d("Resuming from path=$path, positionMs=${state.positionMs}")
901
968
 
902
- // Wait for browser to be available (JS needs to have configured it)
903
969
  val browserManager = player.awaitBrowser().browserManager
904
970
 
905
- // Try to expand the path into a full queue
906
971
  val expanded = path?.let { browserManager.expandQueueFromContextualPath(it) }
907
972
 
908
973
  if (expanded != null) {
@@ -911,7 +976,6 @@ class MediaSessionCallback(private val player: Player) :
911
976
  "Restored ${tracks.size} tracks, starting at index $selectedIndex at ${state.positionMs}ms"
912
977
  )
913
978
 
914
- // Track the source path if this was a contextual path expansion
915
979
  if (BrowserPathHelper.isContextual(path)) {
916
980
  val parentPath = BrowserPathHelper.stripTrackId(path)
917
981
  withContext(Dispatchers.Main) { player.queueSourcePath = parentPath }
@@ -923,7 +987,6 @@ class MediaSessionCallback(private val player: Player) :
923
987
  state.positionMs,
924
988
  )
925
989
  } else {
926
- // Fallback: play just the stored track
927
990
  Timber.d("Queue expansion failed, using stored track: ${state.track.title}")
928
991
  MediaSession.MediaItemsWithStartPosition(
929
992
  ImmutableList.of(TrackFactory.toMedia3(state.track)),
@@ -934,3 +997,163 @@ class MediaSessionCallback(private val player: Player) :
934
997
  }
935
998
  }
936
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
+ }