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 // 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. // source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.86" val defaultVersionName = "0.2.87"
val membyVersionName: String = val membyVersionName: String =
(project.findProperty("memby.versionName") as String?) (project.findProperty("memby.versionName") as String?)
?.trim() ?.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 = internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp =
(availableWidth * if (compact) 0.84f else 0.76f) (availableWidth * if (compact) 0.84f else 0.76f)
.coerceIn(280.dp, 720.dp) .coerceIn(280.dp, 720.dp)
@@ -1804,14 +1839,7 @@ internal fun MediaRow(
// can dispose that item during a focus transfer and make Compose // can dispose that item during a focus transfer and make Compose
// release the same pin twice. LazyListState already preserves the // release the same pin twice. LazyListState already preserves the
// row position; native TV spatial search safely handles row changes. // row position; native TV spatial search safely handles row changes.
// A horizontal lazy list clips along its scroll axis, but focused cards modifier = Modifier.fillMaxWidth().focusGroup(),
// 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(),
) { ) {
itemsIndexed( itemsIndexed(
row.items, row.items,
@@ -285,7 +285,7 @@ internal fun FocusedHomeMetadata(
// whole state: this panel sits beside the hero and redraws on every focus change as // whole state: this panel sits beside the hero and redraws on every focus change as
// it is. // it is.
val homeContent by homeViewModel.content.collectAsStateWithLifecycle() val homeContent by homeViewModel.content.collectAsStateWithLifecycle()
MediaMetadataPanel( MetadataHero(
item = focusedItem, item = focusedItem,
loading = homeContent.loading.isNotEmpty(), loading = homeContent.loading.isNotEmpty(),
sectionLabel = sectionLabel, sectionLabel = sectionLabel,
@@ -165,7 +165,10 @@ internal fun homeHeaderHeight(viewportHeight: Dp, showHero: Boolean): Dp =
if (showHero) { if (showHero) {
(viewportHeight * 0.46f).coerceIn(232.dp, 300.dp) (viewportHeight * 0.46f).coerceIn(232.dp, 300.dp)
} else { } 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( private val LazyListStateMapSaver: Saver<MutableMap<String, LazyListState>, Any> = listSaver(
@@ -1377,7 +1380,9 @@ internal fun HomeScreen(
sectionLabel = selectedDestination.label, sectionLabel = selectedDestination.label,
modifier = Modifier modifier = Modifier
.height(metadataHeight) .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( AnimatedVisibility(
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui package com.ponzischeme89.memby.ui
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.focus.FocusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.takahirom.roborazzi.captureRoboImage 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>) { private fun capture(name: String, movies: List<HomeHeroPick>) {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png")) val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream) .use(BitmapFactory::decodeStream)
@@ -42,6 +42,15 @@ class HomeMovieHeroTest {
assertTrue(540.dp - heroHeight >= 288.dp) 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 @Test
fun `hero alternates new releases and popular movies`() { fun `hero alternates new releases and popular movies`() {
val rows = listOf( val rows = listOf(