This commit is contained in:
ponzischeme89
2026-08-21 11:36:22 +12:00
parent aac8438764
commit 48043dbae2
6 changed files with 131 additions and 12 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.86"
val defaultVersionName = "0.2.87"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -1106,6 +1106,41 @@ fun MediaMetadataPanel(
}
}
/**
* The quiet surface between the focused title details and the scrolling shelves.
*
* The backdrop remains visible through the tint, while the fade at the lower edge keeps
* this integrated with the rows rather than turning it into an opaque panel.
*/
@Composable
internal fun MetadataHero(
item: BaseItem?,
loading: Boolean,
sectionLabel: String,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.clipToBounds()
.background(
Brush.verticalGradient(
0f to MembySurface.copy(alpha = 0.52f),
0.72f to MembySurface.copy(alpha = 0.30f),
1f to Color.Transparent,
),
),
) {
MediaMetadataPanel(
item = item,
loading = loading,
sectionLabel = sectionLabel,
modifier = Modifier
.fillMaxSize()
.padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp),
)
}
}
internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp =
(availableWidth * if (compact) 0.84f else 0.76f)
.coerceIn(280.dp, 720.dp)
@@ -1804,14 +1839,7 @@ internal fun MediaRow(
// can dispose that item during a focus transfer and make Compose
// release the same pin twice. LazyListState already preserves the
// row position; native TV spatial search safely handles row changes.
// A horizontal lazy list clips along its scroll axis, but focused cards
// also draw from a scaled layer in the vertical axis. Its measured height
// already contains artwork, title and secondary metadata; clip that
// complete unit here so the outgoing card cannot paint into another row.
modifier = Modifier
.fillMaxWidth()
.clipToBounds()
.focusGroup(),
modifier = Modifier.fillMaxWidth().focusGroup(),
) {
itemsIndexed(
row.items,
@@ -285,7 +285,7 @@ internal fun FocusedHomeMetadata(
// whole state: this panel sits beside the hero and redraws on every focus change as
// it is.
val homeContent by homeViewModel.content.collectAsStateWithLifecycle()
MediaMetadataPanel(
MetadataHero(
item = focusedItem,
loading = homeContent.loading.isNotEmpty(),
sectionLabel = sectionLabel,
@@ -165,7 +165,10 @@ internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp =
if (showHero) {
(viewportHeight * 0.46f).coerceIn(232.dp, 300.dp)
} else {
(viewportHeight * 0.42f).coerceIn(220.dp, 300.dp)
// The focused metadata surface is also the clean upper boundary for shelves as
// they move vertically. About 20dp more coverage at common TV heights keeps a
// departing card's last text line from being exposed without costing another row.
(viewportHeight * 0.46f).coerceIn(240.dp, 320.dp)
}
private val LazyListStateMapSaver: Saver<MutableMap<String, LazyListState>, Any> = listSaver(
@@ -1377,7 +1380,9 @@ internal fun HomeScreen(
sectionLabel = selectedDestination.label,
modifier = Modifier
.height(metadataHeight)
.padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp),
// LazyColumn is drawn later as a sibling. Keep the hero
// above any transient row draw overflow during D-pad moves.
.zIndex(1f),
)
}
AnimatedVisibility(
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui
import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -19,12 +20,14 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.test.core.app.ApplicationProvider
import androidx.tv.material3.Text
import com.github.takahirom.roborazzi.captureRoboImage
@@ -141,6 +144,80 @@ class HomeMovieHeroScreenshotTest {
)
}
@Test
fun `metadata hero separates focused details from the shelves`() {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
.asImageBitmap()
val item = movie(
"The Last Horizon",
2026,
"Beyond the mapped worlds, one explorer finds an ocean that remembers every visitor.",
8.7,
)
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) {
Box(Modifier.fillMaxSize()) {
Image(
bitmap = artwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
Column(Modifier.fillMaxSize()) {
MetadataHero(
item = item,
loading = false,
sectionLabel = "Movies",
modifier = Modifier
.height(homeHeaderHeight(540.dp, showHero = false))
.zIndex(1f),
)
Text(
"Matt's Favourites",
color = Color(0xFFF1F3F4),
fontSize = 18.sp,
modifier = Modifier.padding(start = 36.dp, top = 8.dp, bottom = 10.dp),
)
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
repeat(5) { index ->
Column(Modifier.weight(1f)) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(16f / 9f)
.clip(RoundedCornerShape(7.dp))
.background(Color(0xFF27343D)),
)
Text(
"Favourite ${index + 1}",
color = Color.White,
fontSize = 13.sp,
modifier = Modifier.padding(top = 6.dp),
)
Text(
"2026 • 1h 47m",
color = Color(0xFF8F9AA3),
fontSize = 11.sp,
modifier = Modifier.padding(top = 2.dp),
)
}
}
}
}
}
}
}
compose.onRoot().captureRoboImage(
"build/screenshots/home-movie-hero/metadata-hero-row-boundary.png",
)
}
private fun capture(name: String, movies: List<HomeHeroPick>) {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
@@ -42,6 +42,15 @@ class HomeMovieHeroTest {
assertTrue(540.dp - heroHeight >= 288.dp)
}
@Test
fun `metadata hero adds a modest scalable row boundary`() {
val compactTvHeight = homeHeaderHeight(540.dp, showHero = false)
assertEquals(248.4f, compactTvHeight.value, 0.01f)
assertTrue(540.dp - compactTvHeight >= 288.dp)
assertEquals(320f, homeHeaderHeight(720.dp, showHero = false).value, 0.01f)
}
@Test
fun `hero alternates new releases and popular movies`() {
val rows = listOf(