This commit is contained in:
ponzischeme89
2026-08-21 13:22:04 +12:00
parent 243b7c8b90
commit fb97980775
6 changed files with 191 additions and 61 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.88"
val defaultVersionName = "0.2.89"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -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<String> {
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 ->
@@ -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,
@@ -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") {
@@ -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<HomeHeroPick>) {
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 ->
@@ -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",
),
)
}
}