diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d342f5..523bb26 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.88" +val defaultVersionName = "0.2.89" 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 6b75c21..265de6a 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -140,6 +140,7 @@ private val QuietText: Color get() = MembyQuietText internal val TvRailCollapsedWidth = 54.dp internal val TvRailExpandedWidth = 184.dp internal val TvRailContentShift = 112.dp +internal val HomeContentHorizontalInset = 48.dp /** * The rail's order, which is this declaration order — see [navigationRailItems], the only @@ -1136,14 +1137,19 @@ internal fun MetadataHero( sectionLabel = sectionLabel, modifier = Modifier .fillMaxSize() - .padding(start = 36.dp, end = 36.dp, top = 24.dp, bottom = 10.dp), + .padding( + start = HomeContentHorizontalInset, + end = HomeContentHorizontalInset, + top = 28.dp, + bottom = 18.dp, + ), ) } } internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp = (availableWidth * if (compact) 0.84f else 0.76f) - .coerceIn(280.dp, 720.dp) + .coerceIn(280.dp, 680.dp) .coerceAtMost(availableWidth) @Composable @@ -1436,7 +1442,7 @@ private fun MetadataContent( } Column( modifier = Modifier.width(contentWidth).fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp), + verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp), ) { Text(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.1.sp) Text( @@ -1473,7 +1479,7 @@ private fun MetadataContent( ItemRatingsStrip( item = item, load = true, - compact = compact, + compact = true, modifier = Modifier.fillMaxWidth(), ) val badges = buildList { @@ -1481,13 +1487,8 @@ private fun MetadataContent( addAll(mediaBadges(item)) } item.membyRecommendationReason?.takeIf(String::isNotBlank)?.let { - Text( - text = it, - color = EmbyGreen, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, + RecommendationReasonCaption( + text = recommendationReasonSummary(it), modifier = Modifier.fillMaxWidth(), ) } @@ -1518,7 +1519,7 @@ private fun ScheduleMetadataContent( ) { Column( modifier = Modifier.width(contentWidth).fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(if (compact) 4.dp else 6.dp), + verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.dp), ) { Text( sectionLabel.uppercase(), @@ -1626,7 +1627,10 @@ private fun MetadataStatus(item: BaseItem) { internal val HomeRowHeaderIconGap = 10.dp /** Vertical gap between a row's header and its cards. One value for every row. */ -internal val HomeRowHeaderSpacing = 6.dp +internal val HomeRowHeaderSpacing = 10.dp + +private val HomeRowCardsTopPadding = 12.dp +private val HomeRowCardsBottomPadding = 24.dp @Composable internal fun HomeRowHeaderIcon(icon: ImageVector, modifier: Modifier = Modifier) { @@ -1694,6 +1698,31 @@ fun MediaBadge(label: String, modifier: Modifier = Modifier) { ) } +internal fun recommendationReasonSummary(reason: String): String = + reason + .split('·', '•') + .asSequence() + .map(String::trim) + .firstOrNull(String::isNotEmpty) + ?: reason.trim() + +@Composable +internal fun RecommendationReasonCaption( + text: String, + modifier: Modifier = Modifier, +) { + Text( + text = text, + color = EmbyGreen.copy(alpha = 0.88f), + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) +} + internal fun mediaBadges(item: BaseItem): List { val video = item.mediaStreams.firstOrNull { it.type.equals("Video", true) } val audio = item.mediaStreams.firstOrNull { it.type.equals("Audio", true) } @@ -1774,7 +1803,7 @@ internal fun MediaRow( ) 6 else 4 Column(modifier, verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing)) { Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = HomeContentHorizontalInset), verticalAlignment = Alignment.CenterVertically, ) { val visual = homeRowVisual(row) @@ -1811,7 +1840,12 @@ internal fun MediaRow( when { row.items.isEmpty() && row.loading -> { LazyRow( - contentPadding = PaddingValues(horizontal = 36.dp, vertical = 9.dp), + contentPadding = PaddingValues( + start = HomeContentHorizontalInset, + end = HomeContentHorizontalInset, + top = HomeRowCardsTopPadding, + bottom = HomeRowCardsBottomPadding, + ), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { items(5) { @@ -1827,13 +1861,21 @@ internal fun MediaRow( row.emptyMessage, color = QuietText, fontSize = 14.sp, - modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp), + modifier = Modifier.padding( + horizontal = HomeContentHorizontalInset, + vertical = 18.dp, + ), ) } else -> { LazyRow( state = rowState, - contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp), + contentPadding = PaddingValues( + start = HomeContentHorizontalInset, + end = HomeContentHorizontalInset, + top = HomeRowCardsTopPadding, + bottom = HomeRowCardsBottomPadding, + ), horizontalArrangement = Arrangement.spacedBy(16.dp), // focusRestorer pins a lazy item. Replacing a For You result set // can dispose that item during a focus transfer and make Compose @@ -1956,16 +1998,15 @@ private fun GalleryJumpButton( private enum class MediaCardFormat { PORTRAIT, LANDSCAPE } -private val MediaRowHorizontalInset = 36.dp +private val MediaRowHorizontalInset = HomeContentHorizontalInset private val MediaRowCardSpacing = 16.dp /** * Sizes a shelf to an integer number of complete cards. * - * The old fractional "cards across" values intentionally left the next card hanging - * outside the viewport. Besides looking clipped, that became more pronounced on smaller - * TVs and whenever the navigation rail reduced the content width. Work from the actual - * row width (including its padding and gaps) so every visible card has a complete edge. + * Work from the actual row width, including the launcher inset, so the first card, the + * heading and the metadata panel stay on one column while the opposite inset still leaves + * a deliberate sliver of the next card visible as a browse cue. */ internal fun responsiveRowCardWidth( availableWidth: Dp, @@ -2187,14 +2228,14 @@ private fun MediaCard( // when a row is moving into view, which shows up as a small hitch on // lower-powered TV hardware. .shadow( - elevation = if (focused) 7.dp else 0.dp, + elevation = if (focused) 5.dp else 0.dp, shape = RoundedCornerShape(MembyCardCorner), ) .clip(RoundedCornerShape(MembyCardCorner)) .background(MembySurfaceRaised) .border( - 2.dp, - if (focused) Color.White else Color.White.copy(alpha = 0.07f), + if (focused) 1.5.dp else 1.dp, + if (focused) Color.White.copy(alpha = 0.76f) else Color.White.copy(alpha = 0.06f), RoundedCornerShape(MembyCardCorner), ), contentAlignment = Alignment.Center, @@ -2293,7 +2334,7 @@ private fun MediaCard( fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 7.dp).fillMaxWidth(), + modifier = Modifier.padding(top = 9.dp).fillMaxWidth(), ) if (showSecondaryMetadata) { // The media type sits *in* the metadata line rather than over the poster: @@ -2304,7 +2345,7 @@ private fun MediaCard( val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 2.dp).fillMaxWidth(), + modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), ) { typeMark?.let { (icon, _) -> Icon( @@ -2328,7 +2369,7 @@ private fun MediaCard( ) } } else { - Spacer(Modifier.height(3.dp)) + Spacer(Modifier.height(5.dp)) } // No ratings strip under a poster. The scores are on the detail page the card // opens and in the metadata panel beside the focused card; a third copy under @@ -2444,7 +2485,7 @@ fun FocusScaleContainer( ) { var focused by remember { mutableStateOf(false) } val scale = animateFloatAsState( - targetValue = if (focused) 1.025f else 1f, + targetValue = if (focused) 1.018f else 1f, animationSpec = tween(95), label = "media-card-focus", ) @@ -2543,8 +2584,7 @@ private fun cardSubtitle( else -> defaultCardSubtitle(item) } -private fun defaultCardSubtitle(item: BaseItem): String = when { - !item.membyRecommendationReason.isNullOrBlank() -> item.membyRecommendationReason +internal fun defaultCardSubtitle(item: BaseItem): String = when { item.isSchedule -> item.membyAirLabel ?: "Coming up" item.isEpisode -> item.seriesName ?: "Up next" item.productionYear != null && item.runtimeMinutes != null -> diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 5f0e422..6e45ba4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -281,8 +282,15 @@ internal fun HomeMovieHero( BoxWithConstraints(modifier.fillMaxWidth()) { val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp) Row( - modifier = Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 16.dp, bottom = 10.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxSize() + .padding( + start = HomeContentHorizontalInset, + end = HomeContentHorizontalInset, + top = 20.dp, + bottom = 18.dp, + ), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { val featured = movies.first() // Down is stated rather than left to Compose's spatial search. The hero cards @@ -402,9 +410,10 @@ private fun FeaturedMovieCard( Column( modifier = Modifier .align(Alignment.CenterStart) - .fillMaxWidth(0.58f) + .fillMaxWidth(0.6f) + .widthIn(max = 560.dp) .fillMaxHeight() - .padding(22.dp), + .padding(start = 26.dp, end = 20.dp, top = 24.dp, bottom = 24.dp), verticalArrangement = Arrangement.Center, ) { Column(Modifier.weight(1f, fill = false)) { @@ -449,27 +458,25 @@ private fun FeaturedMovieCard( // the launcher was silently dropped on exactly the long-titled films // most likely to be leading it. The scores are also on the detail page // this card opens; the reason is not anywhere else. - val reason = pick.reason?.takeIf(String::isNotBlank) + val reason = pick.reason + ?.takeIf(String::isNotBlank) + ?.let(::recommendationReasonSummary) if (reason != null) { - Spacer(Modifier.height(7.dp)) - Text( - reason, - color = MembyAccent, - fontSize = 13.sp, - lineHeight = 17.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + Spacer(Modifier.height(10.dp)) + RecommendationReasonCaption( + text = reason, + modifier = Modifier.fillMaxWidth(0.9f), ) } ItemRatingsStrip( item = item, load = true, - modifier = Modifier.padding(top = 6.dp).fillMaxWidth(), + compact = true, + modifier = Modifier.padding(top = 10.dp).fillMaxWidth(0.88f), ) if (reason == null && titleLines == 1) { item.overview?.takeIf(String::isNotBlank)?.let { overview -> - Spacer(Modifier.height(9.dp)) + Spacer(Modifier.height(11.dp)) Text( overview, color = MembyMutedText, 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 9963e4c..f1479e3 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -1396,7 +1396,10 @@ internal fun HomeScreen( homeStatus.statusMessage ?: "Connection is slow — showing available content", color = MembyMutedText, fontSize = 14.sp, - modifier = Modifier.padding(horizontal = 36.dp, vertical = 4.dp), + modifier = Modifier.padding( + horizontal = HomeContentHorizontalInset, + vertical = 6.dp, + ), ) } LazyColumn( @@ -1404,8 +1407,8 @@ internal fun HomeScreen( modifier = Modifier .weight(1f) .fillMaxWidth(), - contentPadding = PaddingValues(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + contentPadding = PaddingValues(bottom = 128.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { if (selectedDestination == BrowseDestination.SHOWS) { item(key = "my-shows", contentType = "my-shows") { 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 fb219f5..ece5358 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt @@ -197,10 +197,16 @@ class HomeMovieHeroScreenshotTest { "Matt's Favourites", color = Color(0xFFF1F3F4), fontSize = 18.sp, - modifier = Modifier.padding(start = 36.dp, top = 8.dp, bottom = 10.dp), + modifier = Modifier.padding( + start = HomeContentHorizontalInset, + top = 10.dp, + bottom = 12.dp, + ), ) Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HomeContentHorizontalInset), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { repeat(5) { index -> @@ -238,6 +244,50 @@ class HomeMovieHeroScreenshotTest { ) } + @Test + fun `metadata hero keeps recommendation reasons secondary`() { + 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, + ).copy( + membyRecommendationReason = "Matches your Drama viewing · fits your 60-minute window", + membyRatings = listOf( + MediaRating(source = "imdb", name = "IMDb", score = "8.7", scale = "/10"), + MediaRating(source = "tomatoes", name = "Rotten Tomatoes", score = "93", scale = "%"), + ), + ) + + compose.setContent { + PreviewSurface(alignment = Alignment.TopStart) { + Box(Modifier.fillMaxSize()) { + Image( + bitmap = artwork, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + MetadataHero( + item = item, + loading = false, + sectionLabel = "For You", + modifier = Modifier + .fillMaxWidth() + .height(homeHeaderHeight(540.dp, showHero = false)), + ) + } + } + } + + compose.onRoot().captureRoboImage( + "build/screenshots/home-movie-hero/metadata-hero-recommendation-reason.png", + ) + } + private fun capture(name: String, movies: List) { val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png")) .use(BitmapFactory::decodeStream) @@ -267,10 +317,16 @@ class HomeMovieHeroScreenshotTest { "Recently added movies", color = Color(0xFFF1F3F4), fontSize = 18.sp, - modifier = Modifier.padding(start = 36.dp, top = 6.dp, bottom = 9.dp), + modifier = Modifier.padding( + start = HomeContentHorizontalInset, + top = 10.dp, + bottom = 12.dp, + ), ) Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = HomeContentHorizontalInset), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { repeat(5) { index -> diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ResponsiveRowSizingTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ResponsiveRowSizingTest.kt index 385ded7..1748420 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ResponsiveRowSizingTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ResponsiveRowSizingTest.kt @@ -9,7 +9,7 @@ class ResponsiveRowSizingTest { @Test fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() { assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f) - assertEquals(720f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f) + assertEquals(680f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f) } @Test @@ -21,7 +21,7 @@ class ResponsiveRowSizingTest { maxWidth = 218.dp, ) - val occupiedWidth = width * 7 + 16.dp * 6 + 36.dp * 2 + val occupiedWidth = width * 7 + 16.dp * 6 + HomeContentHorizontalInset * 2 assertEquals(1280f, occupiedWidth.value, 0.01f) } @@ -34,9 +34,9 @@ class ResponsiveRowSizingTest { maxWidth = 218.dp, ) - // Four 130dp cards, three gaps and both insets fill this viewport exactly. - assertEquals(130f, width.value, 0.01f) - assertEquals(640f, (width * 4 + 16.dp * 3 + 36.dp * 2).value, 0.01f) + // Four 124dp cards, three gaps and both insets fill this viewport exactly. + assertEquals(124f, width.value, 0.01f) + assertEquals(640f, (width * 4 + 16.dp * 3 + HomeContentHorizontalInset * 2).value, 0.01f) } @Test @@ -49,6 +49,30 @@ class ResponsiveRowSizingTest { ) assertTrue(width <= 360.dp) - assertEquals(341.71f, width.value, 0.01f) + assertEquals(338.29f, width.value, 0.01f) + } + + @Test + fun cardSubtitlePrefersFactsOverRecommendationReason() { + val item = BaseItem( + id = "movie-1", + name = "The Last Horizon", + type = "Movie", + productionYear = 2026, + runtimeMinutes = 124, + membyRecommendationReason = "Matches your Drama viewing · fits your 60-minute window", + ) + + assertEquals("2026 • 2h 4m", defaultCardSubtitle(item)) + } + + @Test + fun recommendationReasonSummaryKeepsTheFirstUsefulClause() { + assertEquals( + "Matches your Drama viewing", + recommendationReasonSummary( + "Matches your Drama viewing · fits your 60-minute window", + ), + ) } }