react-native-audio-browser 0.2.0-next.17 → 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.
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +160 -106
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +377 -110
- package/android/src/main/java/com/audiobrowser/util/BrowserPathHelper.kt +13 -1
- package/android/src/main/res/values/strings.xml +6 -21
- package/android/src/test/java/com/audiobrowser/player/BrowseTilesTest.kt +385 -0
- package/lib/commonjs/features/browser.js +8 -0
- package/lib/commonjs/features/browser.js.map +1 -1
- package/lib/commonjs/web/browser/BrowserManager.js +6 -8
- package/lib/commonjs/web/browser/BrowserManager.js.map +1 -1
- package/lib/commonjs/web/browser/SearchManager.js +4 -7
- package/lib/commonjs/web/browser/SearchManager.js.map +1 -1
- package/lib/module/features/browser.js +8 -0
- package/lib/module/features/browser.js.map +1 -1
- package/lib/module/web/browser/BrowserManager.js +6 -8
- package/lib/module/web/browser/BrowserManager.js.map +1 -1
- package/lib/module/web/browser/SearchManager.js +4 -7
- package/lib/module/web/browser/SearchManager.js.map +1 -1
- package/lib/typescript/src/features/browser.d.ts +8 -0
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/features/browser.ts +8 -0
- package/src/web/browser/BrowserManager.ts +6 -11
- package/src/web/browser/SearchManager.test.ts +75 -0
- package/src/web/browser/SearchManager.ts +7 -10
- 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
|
|
@@ -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) {
|
|
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
|
-
|
|
152
|
-
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
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
448
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
|
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")
|