This commit is contained in:
ponzischeme89
2026-08-24 13:13:44 +12:00
parent a6bd655786
commit 4f95767e2f
7 changed files with 47665 additions and 19 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
val defaultVersionName = "0.3.18"
val defaultVersionName = "0.3.20"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -1266,6 +1266,19 @@ internal fun HomeScreen(
val firstPopulatedRowId = remember(rows) {
rows.firstOrNull { candidate -> candidate.items.isNotEmpty() }?.id
}
// The metadata panel only needs to know whether the focused row is
// Continue Watching, and that answer changes when the viewer crosses into
// or out of one row. Reading focusedHomeRowId where the panel is composed
// read it in the scope that also declares the LazyColumn below, so every
// vertical press invalidated the whole launcher and re-declared the row
// list. Derived, the scope is invalidated only when the boolean actually
// flips.
val focusedRowIsContinueWatching by remember(rows) {
derivedStateOf {
rows.firstOrNull { it.id == focusedHomeRowId }?.kind ==
MediaRowKind.CONTINUE
}
}
// My Shows is a band of its own above the shelves, so on this page it is the
// first stop below the hero and the thing Up out of the topmost shelf has to
// return to. Both moves used to jump over it: the hero pointed Down straight
@@ -1404,9 +1417,7 @@ internal fun HomeScreen(
homeViewModel = homeViewModel,
metadataHeroContentOrder = metadataHeroContentOrder,
metadataHeroTimeRemainingColour = metadataHeroTimeRemainingColour,
isContinueWatchingItem = rows.firstOrNull {
it.id == focusedHomeRowId
}?.kind == MediaRowKind.CONTINUE,
isContinueWatchingItem = focusedRowIsContinueWatching,
modifier = Modifier
.height(metadataHeight)
// LazyColumn is drawn later as a sibling. Keep the hero
@@ -1540,6 +1551,29 @@ internal fun HomeScreen(
row.items.take(8).map { it.id },
)
}
// Only the destination row can act on a vertical focus request,
// but a plain read of pendingRowFocus here happened in every
// composed row's scope — so one press recomposed all of them
// twice, once as the request was posted and again as it was
// consumed and set back to null. Derived per row, a row whose
// id does not match sees null both times and is never
// invalidated; the destination row behaves exactly as before.
val rowFocusRequest by remember(row.id) {
derivedStateOf {
pendingRowFocus?.takeIf { it.rowId == row.id }
}
}
// returnItemId and returnRowId are rewritten by *every* card
// taking focus, horizontal travel included, and reading them
// here read them in every composed row's scope — so moving one
// card along a shelf recomposed every other shelf on screen.
// Derived, a row that does not hold the return target sees null
// and stays untouched.
val rowReturnFocusItemId by remember(row.id) {
derivedStateOf {
returnItemId.takeIf { returnRowId == row.id }
}
}
MediaRow(
modifier = Modifier,
row = row,
@@ -1553,9 +1587,9 @@ internal fun HomeScreen(
heroEntryFocusRequester = heroRowEntryFocusRequester.takeIf {
hasContextualHero && row.id == firstPopulatedRowId
},
returnFocusItemId = returnItemId.takeIf { returnRowId == row.id },
returnFocusItemId = rowReturnFocusItemId,
returnFocusRequester = cardReturnFocusRequester,
verticalFocusRequest = pendingRowFocus,
verticalFocusRequest = rowFocusRequest,
onVerticalFocusRequestConsumed = { requestId ->
if (pendingRowFocus?.requestId == requestId) {
pendingRowFocus = null
@@ -151,6 +151,8 @@ fun LandscapeCard(
MediaCard(
model, item, width, 16f / 9f, preferPrimary = false, showProgress = false,
onFocused, onClick, onLongClick, modifier,
// A shelf card sits under the metadata hero, which names the focused title already.
showTitle = false,
)
}
@@ -181,6 +181,8 @@ fun PosterCard(
MediaCard(
model, item, width, 2f / 3f, preferPrimary = true, showProgress,
onFocused, onClick, onLongClick, modifier,
// A shelf card sits under the metadata hero, which names the focused title already.
showTitle = false,
)
}
@@ -252,6 +254,18 @@ internal fun MediaCard(
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier,
/**
* Whether the card captions itself with the title.
*
* False on the launcher's shelves, where the metadata hero above them is already
* naming whatever holds focus — as the title treatment where there is a logo, as plain
* text where there is not — so a caption there was the same title said twice, once in
* the one place the eye is already reading it. True everywhere the card stands on its
* own with no hero over it, which today is the genre grid: nothing else there says what
* a poster is. The name is in [cardDescription] either way, so a screen reader is told
* regardless of what is drawn.
*/
showTitle: Boolean = true,
) {
val density = LocalDensity.current
val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720)
@@ -386,15 +400,17 @@ internal fun MediaCard(
)
}
}
Text(
model.name,
color = MembyOnSurface,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 9.dp).fillMaxWidth(),
)
if (showTitle) {
Text(
model.name,
color = MembyOnSurface,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 9.dp).fillMaxWidth(),
)
}
if (model.showSecondaryMetadata) {
// The media type sits *in* the metadata line rather than over the poster:
// it is informational, like the year and the runtime beside it, and the
@@ -403,7 +419,11 @@ internal fun MediaCard(
// it cannot make the card taller than one without it.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 4.dp).fillMaxWidth(),
modifier = Modifier
// With no title above it this line is the first thing under the
// artwork, so it takes over the gap the title was holding.
.padding(top = if (showTitle) 4.dp else 9.dp)
.fillMaxWidth(),
) {
model.typeMark?.let { (icon, _) ->
Icon(
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -39,13 +39,33 @@ class BaselineProfileGenerator {
browseHome()
}
/**
* Walks the launcher the way a viewer does, and walks it *vertically*.
*
* Two Down presses were not enough. Moving between shelves is the most expensive thing
* the launcher does — a row is composed as it scrolls in, the focus request is posted
* and consumed, and the hero catches up behind it — and none of that was in the profile,
* so on a television it ran interpreted. Measured on a Chromecast with Google TV, that
* was the difference between a 72ms and a 10ms recomposition at the 90th percentile.
*
* The walk goes down far enough to compose shelves beyond the first viewport and back up
* again, because entering a row from below is a different path from entering it from
* above, and both are on the D-pad.
*/
private fun MacrobenchmarkScope.browseHome() {
repeat(4) { device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT) }
device.waitForIdle()
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN)
repeat(6) {
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN)
device.waitForIdle()
}
// Travelling a shelf is what pulls in the card, artwork and metadata composables
// for a row that was composed by a vertical move rather than by the first frame.
repeat(3) { device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT) }
device.waitForIdle()
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN)
device.waitForIdle()
repeat(6) {
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_UP)
device.waitForIdle()
}
}
}