diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ec8a67..818b6f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index e8b4095..0baee24 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index 6faa6ec..0f2f125 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 7dcb4c6..06b240e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -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, 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( diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt index 4e425a1..5d4d979 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt @@ -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) { val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png")) .use(BitmapFactory::decodeStream) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt index e58a595..e3a34af 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt @@ -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(