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.
- package/android/src/main/java/com/audiobrowser/AudioBrowser.kt +165 -107
- package/android/src/main/java/com/audiobrowser/browser/BrowserManager.kt +11 -29
- package/android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt +409 -186
- 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/package.json +1 -0
- package/lib/typescript/src/features/browser.d.ts +8 -0
- package/lib/typescript/src/features/browser.d.ts.map +1 -1
- package/package.json +2 -2
- 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
|
|
@@ -45,8 +50,12 @@ import kotlinx.coroutines.withContext
|
|
|
45
50
|
import timber.log.Timber
|
|
46
51
|
|
|
47
52
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
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
|
|
123
|
+
private fun createGateMediaItem(gate: Gate): MediaItem =
|
|
158
124
|
createErrorMediaItem(
|
|
159
|
-
|
|
160
|
-
title
|
|
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
|
-
//
|
|
200
|
-
//
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
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(
|
|
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
|
-
//
|
|
346
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
375
|
-
//
|
|
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(
|
|
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
|
|
397
|
-
*
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
|
597
|
-
* subscribed to
|
|
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
|
-
//
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
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
|
-
//
|
|
770
|
-
//
|
|
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
|
-
//
|
|
797
|
-
//
|
|
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
|
-
//
|
|
834
|
-
//
|
|
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
|
-
//
|
|
885
|
-
//
|
|
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
|
+
}
|