0.2.92
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
## 0.2.92 - 2026-08-21
|
||||
- Improved: App design / layout tweaks.
|
||||
- Improved: Many behind the scenes tweaks, bug fixes to make the app run smoother.
|
||||
|
||||
## 0.2.84 - 2026-08-21
|
||||
- Improved: Loading and performance of the continue watching row.
|
||||
|
||||
|
||||
@@ -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.91"
|
||||
val defaultVersionName = "0.2.92"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
|
||||
@@ -40,13 +40,7 @@ internal fun MetadataHero(
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.74f),
|
||||
0.70f to MembySurface.copy(alpha = 0.52f),
|
||||
1f to MembySurface.copy(alpha = 0.18f),
|
||||
),
|
||||
),
|
||||
.background(MembySurface),
|
||||
) {
|
||||
MetadataHeroArtwork(
|
||||
item = item,
|
||||
@@ -55,20 +49,21 @@ internal fun MetadataHero(
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.94f),
|
||||
0.34f to MembySurface.copy(alpha = 0.90f),
|
||||
0.60f to MembySurface.copy(alpha = 0.74f),
|
||||
0.82f to MembySurface.copy(alpha = 0.38f),
|
||||
1f to MembySurface.copy(alpha = 0.18f),
|
||||
0f to MembySurface,
|
||||
0.30f to MembySurface,
|
||||
0.48f to MembySurface.copy(alpha = 0.80f),
|
||||
0.62f to MembySurface.copy(alpha = 0.40f),
|
||||
0.78f to MembySurface.copy(alpha = 0.10f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.28f),
|
||||
0.74f to Color.Transparent,
|
||||
1f to MembySurface.copy(alpha = 0.24f),
|
||||
0f to MembySurface.copy(alpha = 0.18f),
|
||||
0.58f to Color.Transparent,
|
||||
1f to MembySurface.copy(alpha = 0.20f),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -126,16 +121,16 @@ private fun MetadataHeroArtwork(
|
||||
model = request,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.Center,
|
||||
alignment = Alignment.CenterEnd,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to MembySurface,
|
||||
0.16f to MembySurface.copy(alpha = 0.96f),
|
||||
0.42f to MembySurface.copy(alpha = 0.70f),
|
||||
0.76f to MembySurface.copy(alpha = 0.24f),
|
||||
0f to MembySurface.copy(alpha = 0.92f),
|
||||
0.18f to MembySurface.copy(alpha = 0.76f),
|
||||
0.42f to MembySurface.copy(alpha = 0.42f),
|
||||
0.70f to MembySurface.copy(alpha = 0.12f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
),
|
||||
@@ -143,9 +138,9 @@ private fun MetadataHeroArtwork(
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to MembySurface.copy(alpha = 0.28f),
|
||||
0.58f to Color.Transparent,
|
||||
1f to MembySurface.copy(alpha = 0.48f),
|
||||
0f to MembySurface.copy(alpha = 0.16f),
|
||||
0.56f to Color.Transparent,
|
||||
1f to MembySurface.copy(alpha = 0.28f),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -154,4 +149,4 @@ private fun MetadataHeroArtwork(
|
||||
}
|
||||
|
||||
private const val MetadataHeroArtworkCrossfadeMs = 180
|
||||
private const val MetadataHeroArtworkWidthFraction = 0.58f
|
||||
private const val MetadataHeroArtworkWidthFraction = 0.68f
|
||||
|
||||
@@ -120,6 +120,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun ContinueWatchingCard(
|
||||
item: BaseItem,
|
||||
availableWidth: Dp,
|
||||
|
||||
@@ -120,6 +120,7 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
fun LandscapeCard(
|
||||
item: BaseItem,
|
||||
availableWidth: Dp,
|
||||
|
||||
@@ -150,7 +150,7 @@ fun MediaBadge(label: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
|
||||
internal fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
|
||||
val color = when (status) {
|
||||
"available" -> EmbyGreen
|
||||
"downloading" -> Color(0xFF5DA9FF)
|
||||
|
||||
@@ -197,7 +197,7 @@ fun PosterGridCard(
|
||||
|
||||
@Composable
|
||||
|
||||
private fun MediaCard(
|
||||
internal fun MediaCard(
|
||||
item: BaseItem,
|
||||
width: Dp,
|
||||
aspectRatio: Float,
|
||||
|
||||
@@ -135,6 +135,8 @@ private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
||||
private val MutedText: Color get() = MembyMutedText
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
private val BACKDROP_SETTLE_DELAY = 240.milliseconds
|
||||
private val MetadataHeroLogoMaxWidth = 420.dp
|
||||
private val MetadataHeroLogoMaxHeight = 92.dp
|
||||
|
||||
@Composable
|
||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||
@@ -227,8 +229,8 @@ fun MediaMetadataPanel(
|
||||
}
|
||||
|
||||
internal fun metadataPanelContentWidth(availableWidth: Dp, compact: Boolean): Dp =
|
||||
(availableWidth * if (compact) 0.84f else 0.76f)
|
||||
.coerceIn(280.dp, 680.dp)
|
||||
(availableWidth * if (compact) 0.78f else 0.68f)
|
||||
.coerceIn(280.dp, 620.dp)
|
||||
.coerceAtMost(availableWidth)
|
||||
|
||||
@Composable
|
||||
@@ -268,25 +270,65 @@ private fun MetadataContent(
|
||||
ScheduleMetadataContent(item, sectionLabel, contentWidth, compact)
|
||||
return
|
||||
}
|
||||
val repository = ServiceLocator.repository
|
||||
val logoUrl = remember(
|
||||
item.id,
|
||||
item.imageTags,
|
||||
item.parentLogoItemId,
|
||||
item.parentLogoImageTag,
|
||||
repository.showTitleLogo,
|
||||
) {
|
||||
if (repository.showTitleLogo) repository.logoUrl(item) else null
|
||||
}
|
||||
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
||||
Column(
|
||||
modifier = Modifier.width(contentWidth).fillMaxHeight(),
|
||||
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(
|
||||
item.seriesName?.takeIf { item.isEpisode } ?: item.name,
|
||||
color = Color.White,
|
||||
fontSize = if (compact) 25.sp else 29.sp,
|
||||
lineHeight = if (compact) 28.sp else 32.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (logoUrl != null) {
|
||||
Modifier.height(MetadataHeroLogoMaxHeight)
|
||||
} else {
|
||||
Modifier.heightIn(max = MetadataHeroLogoMaxHeight)
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.BottomStart,
|
||||
) {
|
||||
if (logo != null) {
|
||||
TitleLogoImage(
|
||||
logoUrl = logo,
|
||||
contentDescription = item.seriesName?.takeIf { item.isEpisode } ?: item.name,
|
||||
alignment = Alignment.BottomStart,
|
||||
modifier = Modifier
|
||||
.width(MetadataHeroLogoMaxWidth)
|
||||
.height(MetadataHeroLogoMaxHeight),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
item.seriesName?.takeIf { item.isEpisode } ?: item.name,
|
||||
color = Color.White,
|
||||
fontSize = if (compact) 25.sp else 29.sp,
|
||||
lineHeight = if (compact) 28.sp else 32.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (item.isEpisode && item.seriesName != null) {
|
||||
Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
listOfNotNull(item.episodeCode, item.name.takeIf(String::isNotBlank)).joinToString(FactSeparator),
|
||||
color = MutedText,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
val facts = buildList {
|
||||
item.episodeCode?.let(::add)
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
|
||||
@@ -138,6 +138,8 @@ private data class HomeRowVisual(
|
||||
val icon: ImageVector,
|
||||
)
|
||||
|
||||
private val QuietText: Color get() = MembyQuietText
|
||||
|
||||
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
row.id == "continue" -> HomeRowVisual(
|
||||
MembyIcon.PlayCircle.mark,
|
||||
@@ -471,41 +473,6 @@ private fun GalleryJumpButton(
|
||||
|
||||
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
|
||||
|
||||
private val MediaRowHorizontalInset = HomeContentHorizontalInset
|
||||
private val MediaRowCardSpacing = 16.dp
|
||||
|
||||
/**
|
||||
* Sizes a shelf to an integer number of complete cards.
|
||||
*
|
||||
* 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,
|
||||
preferredCardsAcross: Int,
|
||||
minWidth: Dp,
|
||||
maxWidth: Dp,
|
||||
horizontalInset: Dp = MediaRowHorizontalInset,
|
||||
spacing: Dp = MediaRowCardSpacing,
|
||||
): Dp {
|
||||
val usableWidth = (availableWidth - horizontalInset * 2).coerceAtLeast(1.dp)
|
||||
val cardsThatFitMinimum =
|
||||
((usableWidth + spacing) / (minWidth + spacing)).toInt().coerceAtLeast(1)
|
||||
var cardCount = preferredCardsAcross.coerceIn(1, cardsThatFitMinimum)
|
||||
|
||||
// Very wide viewports should gain another complete card instead of a large dead area.
|
||||
while (cardCount < cardsThatFitMinimum) {
|
||||
val candidate = (usableWidth - spacing * (cardCount - 1)) / cardCount
|
||||
if (candidate <= maxWidth) break
|
||||
cardCount += 1
|
||||
}
|
||||
|
||||
return ((usableWidth - spacing * (cardCount - 1)) / cardCount)
|
||||
.coerceAtMost(maxWidth)
|
||||
.coerceAtLeast(1.dp)
|
||||
}
|
||||
|
||||
private fun cardFormat(
|
||||
kind: MediaRowKind,
|
||||
item: BaseItem,
|
||||
|
||||
@@ -10,8 +10,8 @@ import org.junit.Test
|
||||
class ResponsiveRowSizingTest {
|
||||
@Test
|
||||
fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() {
|
||||
assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
|
||||
assertEquals(680f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
|
||||
assertEquals(499.2f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
|
||||
assertEquals(620f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -50,7 +50,7 @@ class WatchedVisibilityScreenshotTest {
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
visible.items.forEach { item ->
|
||||
PortraitMediaCard(
|
||||
PosterCard(
|
||||
item = item,
|
||||
availableWidth = 820.dp,
|
||||
showSecondaryMetadata = true,
|
||||
|
||||
@@ -102,7 +102,6 @@ class ViewerPickerScreenshotTest {
|
||||
viewers = household,
|
||||
canAddViewer = true,
|
||||
activeViewerId = "v1",
|
||||
activeViewerName = "Alessandra",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user