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
|
## 0.2.84 - 2026-08-21
|
||||||
- Improved: Loading and performance of the continue watching row.
|
- 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
|
// 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.91"
|
val defaultVersionName = "0.2.92"
|
||||||
val membyVersionName: String =
|
val membyVersionName: String =
|
||||||
(project.findProperty("memby.versionName") as String?)
|
(project.findProperty("memby.versionName") as String?)
|
||||||
?.trim()
|
?.trim()
|
||||||
|
|||||||
@@ -40,13 +40,7 @@ internal fun MetadataHero(
|
|||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.clipToBounds()
|
.clipToBounds()
|
||||||
.background(
|
.background(MembySurface),
|
||||||
Brush.verticalGradient(
|
|
||||||
0f to MembySurface.copy(alpha = 0.74f),
|
|
||||||
0.70f to MembySurface.copy(alpha = 0.52f),
|
|
||||||
1f to MembySurface.copy(alpha = 0.18f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
) {
|
) {
|
||||||
MetadataHeroArtwork(
|
MetadataHeroArtwork(
|
||||||
item = item,
|
item = item,
|
||||||
@@ -55,20 +49,21 @@ internal fun MetadataHero(
|
|||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to MembySurface.copy(alpha = 0.94f),
|
0f to MembySurface,
|
||||||
0.34f to MembySurface.copy(alpha = 0.90f),
|
0.30f to MembySurface,
|
||||||
0.60f to MembySurface.copy(alpha = 0.74f),
|
0.48f to MembySurface.copy(alpha = 0.80f),
|
||||||
0.82f to MembySurface.copy(alpha = 0.38f),
|
0.62f to MembySurface.copy(alpha = 0.40f),
|
||||||
1f to MembySurface.copy(alpha = 0.18f),
|
0.78f to MembySurface.copy(alpha = 0.10f),
|
||||||
|
1f to Color.Transparent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
0f to MembySurface.copy(alpha = 0.28f),
|
0f to MembySurface.copy(alpha = 0.18f),
|
||||||
0.74f to Color.Transparent,
|
0.58f to Color.Transparent,
|
||||||
1f to MembySurface.copy(alpha = 0.24f),
|
1f to MembySurface.copy(alpha = 0.20f),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -126,16 +121,16 @@ private fun MetadataHeroArtwork(
|
|||||||
model = request,
|
model = request,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
alignment = Alignment.Center,
|
alignment = Alignment.CenterEnd,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.horizontalGradient(
|
Brush.horizontalGradient(
|
||||||
0f to MembySurface,
|
0f to MembySurface.copy(alpha = 0.92f),
|
||||||
0.16f to MembySurface.copy(alpha = 0.96f),
|
0.18f to MembySurface.copy(alpha = 0.76f),
|
||||||
0.42f to MembySurface.copy(alpha = 0.70f),
|
0.42f to MembySurface.copy(alpha = 0.42f),
|
||||||
0.76f to MembySurface.copy(alpha = 0.24f),
|
0.70f to MembySurface.copy(alpha = 0.12f),
|
||||||
1f to Color.Transparent,
|
1f to Color.Transparent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -143,9 +138,9 @@ private fun MetadataHeroArtwork(
|
|||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
0f to MembySurface.copy(alpha = 0.28f),
|
0f to MembySurface.copy(alpha = 0.16f),
|
||||||
0.58f to Color.Transparent,
|
0.56f to Color.Transparent,
|
||||||
1f to MembySurface.copy(alpha = 0.48f),
|
1f to MembySurface.copy(alpha = 0.28f),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -154,4 +149,4 @@ private fun MetadataHeroArtwork(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const val MetadataHeroArtworkCrossfadeMs = 180
|
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 kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
fun ContinueWatchingCard(
|
fun ContinueWatchingCard(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
availableWidth: Dp,
|
availableWidth: Dp,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
@Composable
|
||||||
fun LandscapeCard(
|
fun LandscapeCard(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
availableWidth: Dp,
|
availableWidth: Dp,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ fun MediaBadge(label: String, modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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) {
|
val color = when (status) {
|
||||||
"available" -> EmbyGreen
|
"available" -> EmbyGreen
|
||||||
"downloading" -> Color(0xFF5DA9FF)
|
"downloading" -> Color(0xFF5DA9FF)
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ fun PosterGridCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
||||||
private fun MediaCard(
|
internal fun MediaCard(
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
width: Dp,
|
width: Dp,
|
||||||
aspectRatio: Float,
|
aspectRatio: Float,
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f)
|
|||||||
private val MutedText: Color get() = MembyMutedText
|
private val MutedText: Color get() = MembyMutedText
|
||||||
private val QuietText: Color get() = MembyQuietText
|
private val QuietText: Color get() = MembyQuietText
|
||||||
private val BACKDROP_SETTLE_DELAY = 240.milliseconds
|
private val BACKDROP_SETTLE_DELAY = 240.milliseconds
|
||||||
|
private val MetadataHeroLogoMaxWidth = 420.dp
|
||||||
|
private val MetadataHeroLogoMaxHeight = 92.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
fun BackdropLayer(item: BaseItem?, modifier: Modifier = Modifier) {
|
||||||
@@ -227,8 +229,8 @@ fun MediaMetadataPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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.78f else 0.68f)
|
||||||
.coerceIn(280.dp, 680.dp)
|
.coerceIn(280.dp, 620.dp)
|
||||||
.coerceAtMost(availableWidth)
|
.coerceAtMost(availableWidth)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -268,25 +270,65 @@ private fun MetadataContent(
|
|||||||
ScheduleMetadataContent(item, sectionLabel, contentWidth, compact)
|
ScheduleMetadataContent(item, sectionLabel, contentWidth, compact)
|
||||||
return
|
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(
|
Column(
|
||||||
modifier = Modifier.width(contentWidth).fillMaxHeight(),
|
modifier = Modifier.width(contentWidth).fillMaxHeight(),
|
||||||
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 8.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(sectionLabel.uppercase(), color = EmbyGreen, fontSize = 11.sp, fontWeight = FontWeight.Bold, letterSpacing = 1.1.sp)
|
||||||
|
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(
|
Text(
|
||||||
item.seriesName?.takeIf { item.isEpisode } ?: item.name,
|
item.seriesName?.takeIf { item.isEpisode } ?: item.name,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontSize = if (compact) 25.sp else 29.sp,
|
fontSize = if (compact) 25.sp else 29.sp,
|
||||||
lineHeight = if (compact) 28.sp else 32.sp,
|
lineHeight = if (compact) 28.sp else 32.sp,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.isEpisode && item.seriesName != null) {
|
||||||
|
Text(
|
||||||
|
listOfNotNull(item.episodeCode, item.name.takeIf(String::isNotBlank)).joinToString(FactSeparator),
|
||||||
|
color = MutedText,
|
||||||
|
fontSize = 14.sp,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (item.isEpisode && item.seriesName != null) {
|
|
||||||
Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
|
||||||
}
|
}
|
||||||
val facts = buildList {
|
val facts = buildList {
|
||||||
item.episodeCode?.let(::add)
|
|
||||||
item.productionYear?.let { add(it.toString()) }
|
item.productionYear?.let { add(it.toString()) }
|
||||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ private data class HomeRowVisual(
|
|||||||
val icon: ImageVector,
|
val icon: ImageVector,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private val QuietText: Color get() = MembyQuietText
|
||||||
|
|
||||||
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||||
row.id == "continue" -> HomeRowVisual(
|
row.id == "continue" -> HomeRowVisual(
|
||||||
MembyIcon.PlayCircle.mark,
|
MembyIcon.PlayCircle.mark,
|
||||||
@@ -471,41 +473,6 @@ private fun GalleryJumpButton(
|
|||||||
|
|
||||||
private enum class MediaCardFormat { PORTRAIT, LANDSCAPE }
|
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(
|
private fun cardFormat(
|
||||||
kind: MediaRowKind,
|
kind: MediaRowKind,
|
||||||
item: BaseItem,
|
item: BaseItem,
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import org.junit.Test
|
|||||||
class ResponsiveRowSizingTest {
|
class ResponsiveRowSizingTest {
|
||||||
@Test
|
@Test
|
||||||
fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() {
|
fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() {
|
||||||
assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
|
assertEquals(499.2f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
|
||||||
assertEquals(680f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
|
assertEquals(620f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class WatchedVisibilityScreenshotTest {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||||
) {
|
) {
|
||||||
visible.items.forEach { item ->
|
visible.items.forEach { item ->
|
||||||
PortraitMediaCard(
|
PosterCard(
|
||||||
item = item,
|
item = item,
|
||||||
availableWidth = 820.dp,
|
availableWidth = 820.dp,
|
||||||
showSecondaryMetadata = true,
|
showSecondaryMetadata = true,
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ class ViewerPickerScreenshotTest {
|
|||||||
viewers = household,
|
viewers = household,
|
||||||
canAddViewer = true,
|
canAddViewer = true,
|
||||||
activeViewerId = "v1",
|
activeViewerId = "v1",
|
||||||
activeViewerName = "Alessandra",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user