Skip to content

Commit b7bbdea

Browse files
committed
feat!: replace Section.subtitle with a global viewMoreTitle option
Section.subtitle had exactly one renderer anywhere: the second line of the row Android Auto appends under a section carrying a `path`. It was a link label wearing a subtitle's name, and it made per-section copy mandatory for a row most consumers never see drawn. That row's title now comes from a global `viewMoreTitle` browser option, defaulting to "View more". A callback rather than a string so it re-resolves once per content generation: an app whose locale is its own rather than the system's can switch language and pick the new copy up via invalidateAllContent(), which a value frozen at configure time cannot express. Also drops the `display == GRID` gate in toMediaItems. No Android Auto header is tappable at any display, so a pathed *list* section previously swallowed its `path` — its destination unreachable, with no diagnostic. CarPlay is unchanged: its tile rows already navigate on their own, and a list section gets no invented row — Apple documents no "see all" affordance for one, and it would spend an item from the template's cap. Per-section wording stays available by authoring the link yourself: leave `path` unset and append an ordinary browsable Track to the section's `children`. The row is Android-only, so that is a platform branch in the content you emit, not a new API. Closes #137. Amends #134. BREAKING CHANGE: `Section.subtitle` is removed. A section's view-all row is titled by the `viewMoreTitle` browser option instead, and a section with a `path` now gets that row at every `display`, not only grids.
1 parent 235c074 commit b7bbdea

36 files changed

Lines changed: 587 additions & 119 deletions

android/src/main/java/com/audiobrowser/AudioBrowser.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
203203
singleTrack = null,
204204
handleTrackLoad = null,
205205
androidControllerOfflineError = null,
206+
viewMoreTitle = null,
206207
carPlayLoadingTitle = null,
207208
resolveAlbumPath = null,
208209
formatNavigationError = null,
@@ -369,6 +370,7 @@ class AudioBrowser : HybridAudioBrowserSpec(), ServiceConnection {
369370
routes = _configuration.routes,
370371
singleTrack = _configuration.singleTrack ?: false,
371372
androidControllerOfflineError = _configuration.androidControllerOfflineError ?: true,
373+
viewMoreTitle = _configuration.viewMoreTitle,
372374
)
373375
}
374376

@@ -1571,6 +1573,7 @@ private fun NativeBrowserConfiguration.strippingJSCallbacks() =
15711573
requestResolver = null,
15721574
browseResolver = null,
15731575
handleTrackLoad = null,
1576+
viewMoreTitle = null,
15741577
resolveAlbumPath = null,
15751578
formatNavigationError = null,
15761579
request = request?.strippingJSCallbacks(),

android/src/main/java/com/audiobrowser/browser/BrowserManager.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import com.margelo.nitro.audiobrowser.Section
2424
import com.margelo.nitro.audiobrowser.StyleDisplay
2525
import com.margelo.nitro.audiobrowser.Track
2626
import com.margelo.nitro.audiobrowser.TransformableRequestConfig
27+
import com.margelo.nitro.core.Promise
2728
import kotlinx.coroutines.Dispatchers
2829
import kotlinx.coroutines.async
2930
import kotlinx.coroutines.awaitAll
@@ -133,6 +134,12 @@ class BrowserManager {
133134
private var resolvedRequestLayer: TransformableRequestConfig? = null
134135
private var resolvedBrowseLayer: TransformableRequestConfig? = null
135136

137+
// The "view more" row title, cached on the same generation as the layers above — see
138+
// viewMoreTitle(). Kept separate from them because it is resolved lazily (only a browse that
139+
// actually appends a row asks for it), not eagerly alongside every request.
140+
private var resolvedViewMoreTitleGeneration = -1
141+
private var resolvedViewMoreTitle = DEFAULT_VIEW_MORE_TITLE
142+
136143
/** Test-only accessors for the resolver-layer cache state (see ensureLayersResolved). */
137144
internal val layerGenerationForTest: Int
138145
get() = layerGeneration
@@ -1129,6 +1136,14 @@ class BrowserManager {
11291136

11301137
/** Internal path used for search */
11311138
internal const val SEARCH_ROUTE_PATH = "__search__"
1139+
1140+
/**
1141+
* Title of the appended "view more" row when the consumer sets no
1142+
* [BrowserConfig.viewMoreTitle]. English in code, like [defaultFormattedError]'s error copy — a
1143+
* default that renders beats a section whose `path` is unreachable, and it still resolves when
1144+
* the JS runtime is gone.
1145+
*/
1146+
internal const val DEFAULT_VIEW_MORE_TITLE = "View more"
11321147
}
11331148

11341149
/**
@@ -1304,6 +1319,33 @@ class BrowserManager {
13041319
return resolvedRequestLayer
13051320
}
13061321

1322+
/**
1323+
* The title for the "view more" row appended to a pathed section on a surface whose header cannot
1324+
* be tapped (Android Auto).
1325+
*
1326+
* Resolved from [BrowserConfig.viewMoreTitle] once per content generation — the same generation
1327+
* the resolver layers use, so `invalidateAllContent()` re-reads it and an app that switched
1328+
* language mid-drive gets the new copy without reconfiguring. Falls back to
1329+
* [DEFAULT_VIEW_MORE_TITLE] when unset, blank, or throwing: a row with the default copy still
1330+
* reaches the section's page, an untitled one does not.
1331+
*/
1332+
internal suspend fun viewMoreTitle(): String {
1333+
val generation = layerGeneration
1334+
if (resolvedViewMoreTitleGeneration == generation) return resolvedViewMoreTitle
1335+
val title =
1336+
try {
1337+
config.viewMoreTitle?.invoke()?.await()?.takeIf { it.isNotBlank() }
1338+
} catch (e: Exception) {
1339+
Timber.e(e, "viewMoreTitle callback failed; falling back to the default")
1340+
null
1341+
} ?: DEFAULT_VIEW_MORE_TITLE
1342+
// A newer generation started while awaiting — use this result, but don't cache it as current.
1343+
if (generation != layerGeneration) return title
1344+
resolvedViewMoreTitle = title
1345+
resolvedViewMoreTitleGeneration = generation
1346+
return title
1347+
}
1348+
13071349
/**
13081350
* Builds the HTTP request for an API-backed path by layering request (shared) → kind
13091351
* (browse/search) → route configs. Each layer's transform receives the previous layer's output; a
@@ -1502,6 +1544,8 @@ data class BrowserConfig(
15021544
// Behavior
15031545
val singleTrack: Boolean = false,
15041546
val androidControllerOfflineError: Boolean = true,
1547+
// Titles the "view more" row appended to a pathed section. See BrowserManager.viewMoreTitle.
1548+
val viewMoreTitle: (() -> Promise<String>)? = null,
15051549
) {
15061550
/** Returns true if search functionality is configured (either callback or config). */
15071551
val hasSearch: Boolean

android/src/main/java/com/audiobrowser/browser/JsonModels.kt

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ data class JsonTrackRequest(
4040
@Serializable
4141
data class JsonSection(
4242
val title: String? = null,
43-
val subtitle: String? = null,
4443
val style: JsonElement? = null,
4544
val path: String? = null,
4645
val children: List<JsonTrack>,
@@ -150,7 +149,6 @@ private fun String?.toCarPlaySiriListButtonPosition(): CarPlaySiriListButtonPosi
150149
fun JsonSection.toNitro(): Section {
151150
return Section(
152151
title = title,
153-
subtitle = subtitle,
154152
style = style.toSectionStyle(),
155153
path = path,
156154
children = children.map { it.toNitro() }.toTypedArray(),

android/src/main/java/com/audiobrowser/browser/SectionScope.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ object SectionScope {
5959
* search results — ADR 0010).
6060
*/
6161
fun untitledSection(children: Array<Track>): Section =
62-
Section(title = null, subtitle = null, style = null, path = null, children = children)
62+
Section(title = null, style = null, path = null, children = children)
6363

6464
/**
6565
* The canonical sectioned shape: `sections` wins when present; plain `children` is authoring sugar

android/src/main/java/com/audiobrowser/player/MediaSessionCallback.kt

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import com.margelo.nitro.audiobrowser.PlayerCapabilities
3232
import com.margelo.nitro.audiobrowser.RemoteButtonLayout
3333
import com.margelo.nitro.audiobrowser.SearchParams
3434
import com.margelo.nitro.audiobrowser.Section
35-
import com.margelo.nitro.audiobrowser.StyleDisplay
3635
import com.margelo.nitro.audiobrowser.Track
3736
import kotlinx.coroutines.CancellationException
3837
import kotlinx.coroutines.CoroutineScope
@@ -419,41 +418,41 @@ class MediaSessionCallback(private val player: Player) :
419418
/**
420419
* Flattens a page's sections to MediaItems for browse delivery — the Media3 boundary where
421420
* sections die (ADR 0010): the MediaBrowser protocol has no section node, so each child is
422-
* stamped with its owning section's title/style hints. A grid-displayed section with a `path`
423-
* gains a browsable "view all" link under the same header. Http(s) artwork routes through the
424-
* content:// provider so Android Auto can load it via the ArtworkContentProvider.
421+
* stamped with its owning section's title/style hints. A section with a `path` gains a browsable
422+
* "view more" row under the same header — at every `display`, because no Android Auto header is
423+
* tappable at any of them, so the row is the section's only "view all" affordance here. Http(s)
424+
* artwork routes through the content:// provider so Android Auto can load it via the
425+
* ArtworkContentProvider.
425426
*/
426-
private fun toMediaItems(sections: List<Section>): List<MediaItem> {
427+
private suspend fun toMediaItems(sections: List<Section>): List<MediaItem> {
427428
val registry = player.browseArtworkRegistry
428429
val authority = com.audiobrowser.util.ArtworkUris.authorityFor(player.context.packageName)
429430
return sections.flatMap { section ->
430431
// Android Auto has no disabled affordance, so an unavailable track hides
431432
// — never a normal-looking dead row (Track.disabled's rendering ladder).
432-
val visibleChildren = section.children.filter { it.disabled != true }
433-
val items =
434-
visibleChildren.map { TrackFactory.toBrowseMediaItem(it, registry, authority, section) }
435-
val isTileSection = section.style?.display == StyleDisplay.GRID
436-
// No "view all" under an empty section — CarPlay skips empty sections
433+
val children = section.children.filter { it.disabled != true }
434+
val items = children.map { TrackFactory.toBrowseMediaItem(it, registry, authority, section) }
435+
// No "view more" under an empty section — CarPlay skips empty sections
437436
// outright, and a lone navigation tile under a header is a dead end.
438-
if (isTileSection && section.path != null && visibleChildren.isNotEmpty()) {
439-
items +
440-
TrackFactory.toBrowseMediaItem(
441-
TrackFactory.navigationTrack(section),
442-
registry,
443-
authority,
444-
section,
445-
)
446-
} else {
447-
items
448-
}
437+
if (section.path == null || children.isEmpty()) return@flatMap items
438+
// The browser is already up at every call site, so awaiting it here is a field read, and
439+
// viewMoreTitle is memoized per content generation — both are free after the first row.
440+
val title = player.awaitBrowser().browserManager.viewMoreTitle()
441+
items +
442+
TrackFactory.toBrowseMediaItem(
443+
TrackFactory.navigationTrack(section, title),
444+
registry,
445+
authority,
446+
section,
447+
)
449448
}
450449
}
451450

452451
/**
453452
* Flat lists (tabs, search results) convert as one untitled list section — which contributes no
454453
* group or style hints — so exactly one Track→MediaItem path exists at the Media3 boundary.
455454
*/
456-
private fun toFlatMediaItems(tracks: List<Track>): List<MediaItem> =
455+
private suspend fun toFlatMediaItems(tracks: List<Track>): List<MediaItem> =
457456
toMediaItems(listOf(untitledSection(tracks.toTypedArray())))
458457

459458
override fun onGetItem(

android/src/main/java/com/audiobrowser/util/TrackFactory.kt

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,25 @@ object TrackFactory {
2525
}
2626

2727
/**
28-
* A synthetic browsable Track for a section's "view all" surface — the section has a path, title,
29-
* and label, but no Track (ADR 0010). It carries the section's declared item block as its own,
28+
* A synthetic browsable Track for a section's "view more" row — the section has a path but no
29+
* Track to hang it on (ADR 0010). It carries the section's declared item block as its own,
3030
* `display` included: the section's declaration about this content's layout is the only declared
3131
* promise the "view all" page can ever have (there is no consumer-authored handle to declare one
3232
* on), and without it Android Auto would always render that page as a list.
33+
*
34+
* [title] is the consumer's `viewMoreTitle`, one string for every section — not the section's own
35+
* title, which the group header above the row already shows.
3336
*/
34-
fun navigationTrack(section: Section): Track =
37+
fun navigationTrack(section: Section, title: String): Track =
3538
Track(
3639
id = null,
3740
path = section.path,
3841
src = null,
3942
artwork = null,
4043
artworkSource = null,
4144
request = null,
42-
title = section.title ?: "",
43-
subtitle = section.subtitle,
45+
title = title,
46+
subtitle = null,
4447
artist = null,
4548
albumPath = null,
4649
album = null,
Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,45 @@
11
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Default copy for the two error tiles the library invents in the Android Auto
4+
browse list. These rows have no consumer-authored Track behind them, so there
5+
is nothing in the browse tree to read a title from; English here because a
6+
default that renders beats a blank row.
7+
8+
Resources rather than a browser-config option — the route the library's
9+
cross-platform copy takes (`formatNavigationError`, `carPlayLoadingTitle`,
10+
`viewMoreTitle`) — because these two are not cross-platform. They exist only
11+
because Media3 drops the message from `LibraryResult.ofError` on the legacy
12+
browse bridge, so a row is the sole signal available; CarPlay has no
13+
counterpart, stating failures in the list's centered empty view instead
14+
(CarPlayController.showMessage). Android-only copy in the Android-idiomatic
15+
place: an app re-skins either by declaring the same name in its own
16+
strings.xml, and a `values-<locale>/` file can add translations later without
17+
touching the API.
18+
-->
219
<resources>
3-
<!-- Error message shown in Android Auto when network is offline -->
20+
<!--
21+
The offline tile: shown in place of a page's contents when the device is
22+
offline and the consumer left `androidControllerOfflineError` on. Distinct
23+
from the browse-error tile below — this one is used only when the network
24+
monitor says we are actually offline, so it may name the cause. The
25+
subtitle is the tile's second line.
26+
-->
427
<string name="audio_browser_offline_error">No internet connection</string>
528
<string name="audio_browser_offline_error_subtitle">Check your connection and try again</string>
629

7-
<!-- Generic error message shown in Android Auto when browsing fails -->
30+
<!--
31+
The browse-error tile: shown when a browse fails while ONLINE — a server
32+
error, a bad status, a timeout waiting for the JS browser. Deliberately
33+
vague, and deliberately not "no internet connection": the genuinely-offline
34+
case is caught earlier and gets the tile above, so anything reaching this
35+
one has a working connection and would be misdiagnosed by that wording.
36+
37+
A tile rather than an error dialog because Media3 drops the message from
38+
`LibraryResult.ofError` on the legacy browse bridge, which would leave a
39+
bare "No items" screen. Verified on a head unit (2026-06): the Android Auto
40+
browse list never renders error text by any other route, so tiles are the
41+
only in-browse signal the library controls.
42+
-->
843
<string name="audio_browser_browse_error">Something went wrong</string>
944
<string name="audio_browser_browse_error_subtitle">Please try again later</string>
1045
</resources>

android/src/test/java/com/audiobrowser/TestFixtures.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,9 @@ object TestFixtures {
6161
fun section(
6262
children: Array<Track> = emptyArray(),
6363
title: String? = null,
64-
subtitle: String? = null,
6564
style: SectionStyle? = null,
6665
path: String? = null,
67-
) = Section(title = title, subtitle = subtitle, style = style, path = path, children = children)
66+
) = Section(title = title, style = style, path = path, children = children)
6867

6968
fun transformableConfig(
7069
baseUrl: String? = null,

android/src/test/java/com/audiobrowser/browser/BrowserManagerLayerResolutionTest.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,13 @@ class BrowserManagerLayerResolutionTest {
144144
assertSame(request, browserManager.resolvedRequestLayerForTest)
145145
assertNull(browserManager.resolvedBrowseLayerForTest)
146146
}
147+
148+
@Test
149+
fun `viewMoreTitle falls back to the in-code default when unconfigured`() = runTest {
150+
// The only viewMoreTitle path a JVM test can reach — a configured callback returns a Nitro
151+
// Promise, see the resolver-thunk gap noted above.
152+
browserManager.config = BrowserConfig()
153+
154+
assertEquals(BrowserManager.DEFAULT_VIEW_MORE_TITLE, browserManager.viewMoreTitle())
155+
}
147156
}

android/src/test/java/com/audiobrowser/util/MediaExtrasBuilderTest.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,9 @@ class MediaExtrasBuilderTest {
130130
path = "/popular",
131131
style = sectionStyle(display = StyleDisplay.GRID, artworkRendering = null, gridWrap = false),
132132
)
133-
val navigation = TrackFactory.navigationTrack(section)
133+
val navigation = TrackFactory.navigationTrack(section, "View more")
134134
assertEquals(StyleDisplay.GRID, navigation.style?.display)
135+
assertEquals("View more", navigation.title)
135136

136137
val extras = MediaExtrasBuilder.build(navigation)
137138
assertEquals(

0 commit comments

Comments
 (0)