From 32019f2e20f4c335a0964efb094412e601531765 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Fri, 21 Aug 2026 22:07:02 +1200 Subject: [PATCH] 0.2.95 - Refactor --- app/build.gradle.kts | 2 +- .../ponzischeme89/memby/ui/HomeOverlays.kt | 1 - .../com/ponzischeme89/memby/ui/HomeScreen.kt | 1 + .../memby/ui/alerts/AlertsPage.kt | 3 +- .../components/media/ContinueWatchingCard.kt | 117 +-------- .../ui/components/media/LandscapeCard.kt | 12 +- .../memby/ui/components/media/PosterCard.kt | 241 +++++++++--------- .../memby/ui/home/HomeNavigation.kt | 48 +++- .../ponzischeme89/memby/ui/home/HomeRow.kt | 62 +---- .../memby/ui/NavigationRailTest.kt | 6 + .../memby/ui/genre/GenreRailScreenshotTest.kt | 9 +- 11 files changed, 190 insertions(+), 312 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 668a52d..a3cb0a1 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.94" +val defaultVersionName = "0.2.95" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt index ae4065e..a386093 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeOverlays.kt @@ -2,7 +2,6 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.mark -import com.ponzischeme89.memby.data.decodeLineList import android.os.Build import android.text.format.DateFormat import androidx.compose.foundation.background 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 40739d9..63c1789 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -68,6 +68,7 @@ import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.ponzischeme89.memby.ServiceLocator import com.ponzischeme89.memby.data.Settings +import com.ponzischeme89.memby.data.decodeLineList import com.ponzischeme89.memby.data.friendlyEmbyError import com.ponzischeme89.memby.data.analytics.PlaybackJourney import com.ponzischeme89.memby.data.analytics.playbackEntryPointFor diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt index 765590d..bd0f357 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -1,5 +1,6 @@ package com.ponzischeme89.memby.ui.alerts +import android.annotation.SuppressLint import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.mark import android.provider.Settings as AndroidSettings @@ -117,7 +118,7 @@ fun MyAlertsPage( onDismiss: (UserNotification) -> Unit, onDismissAll: (List) -> Unit, onClose: () -> Unit, - modifier: Modifier = Modifier, + @SuppressLint("ModifierParameter") modifier: Modifier = Modifier, ) { var tab by remember { mutableStateOf(AlertsTab.INBOX) } var page by remember { mutableStateOf(0) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt index 8ed8e09..c06fe70 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/ContinueWatchingCard.kt @@ -1,124 +1,15 @@ -package com.ponzischeme89.memby.ui +package com.ponzischeme89.memby.ui.components.media -import com.ponzischeme89.memby.ui.theme.MembyIcon -import com.ponzischeme89.memby.ui.theme.mark -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.onLongClick -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import androidx.tv.material3.Icon -import androidx.tv.material3.Text -import coil.compose.AsyncImage -import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.R -import com.ponzischeme89.memby.BuildConfig -import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.model.BaseItem -import com.ponzischeme89.memby.data.model.MembyViewer -import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel -import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH -import com.ponzischeme89.memby.ui.detail.channelLabel -import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel -import com.ponzischeme89.memby.ui.detail.formatRuntime -import com.ponzischeme89.memby.ui.theme.FactSeparator -import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyAccentInk -import com.ponzischeme89.memby.ui.theme.MembyCardCorner -import com.ponzischeme89.memby.ui.theme.MembyChipCorner -import com.ponzischeme89.memby.ui.theme.MembyControlSurface -import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised -import com.ponzischeme89.memby.ui.theme.MembyMutedText -import com.ponzischeme89.memby.ui.theme.MembyOnSurface -import com.ponzischeme89.memby.ui.theme.MembyPanelCorner -import com.ponzischeme89.memby.ui.theme.MembyQuietText -import com.ponzischeme89.memby.ui.theme.MembySurface -import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised -import com.ponzischeme89.memby.ui.theme.ValueSeparator -import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig -import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels -import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig -import java.util.Locale -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds +import com.ponzischeme89.memby.ui.ResumableMediaCard +import com.ponzischeme89.memby.ui.responsiveRowCardWidth +import com.ponzischeme89.memby.ui.toResumableMediaCardModel @Composable fun ContinueWatchingCard( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt index 2668209..7642480 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/LandscapeCard.kt @@ -138,9 +138,17 @@ fun LandscapeCard( else -> 4 } val width = responsiveRowCardWidth(availableWidth, cardsAcross, 164.dp, 360.dp) + val model = remember(item, showSecondaryMetadata, showWatchedEpisodeCount) { + item.toMediaCardUiModel( + showProgress = false, + showSecondaryMetadata, + showWatchedEpisodeCount, + showMediaTypeIcon = false, + ) + } MediaCard( - item, width, 16f / 9f, preferPrimary = false, showProgress = false, - showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, + model, item, width, 16f / 9f, preferPrimary = false, showProgress = false, + onFocused, onClick, onLongClick, modifier, ) } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt index 41847f8..0c4a9dd 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/PosterCard.kt @@ -2,123 +2,54 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.mark -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout -import androidx.compose.ui.input.key.onPreviewKeyEvent -import androidx.compose.ui.input.key.Key -import androidx.compose.ui.input.key.KeyEventType -import androidx.compose.ui.input.key.key -import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.onLongClick -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex import androidx.tv.material3.Icon import androidx.tv.material3.Text import coil.compose.AsyncImage import coil.request.ImageRequest import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.R -import com.ponzischeme89.memby.BuildConfig -import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.model.BaseItem -import com.ponzischeme89.memby.data.model.MembyViewer -import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel -import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH -import com.ponzischeme89.memby.ui.detail.channelLabel -import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel import com.ponzischeme89.memby.ui.detail.formatRuntime -import com.ponzischeme89.memby.ui.theme.FactSeparator import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyAccentInk import com.ponzischeme89.memby.ui.theme.MembyCardCorner -import com.ponzischeme89.memby.ui.theme.MembyChipCorner -import com.ponzischeme89.memby.ui.theme.MembyControlSurface -import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyOnSurface -import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyQuietText import com.ponzischeme89.memby.ui.theme.MembySurface import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised -import com.ponzischeme89.memby.ui.theme.ValueSeparator -import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig -import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels -import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig -import java.util.Locale -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.time.Duration.Companion.milliseconds // The launcher's names for the shared tokens. Both surfaces read from one palette now // (ui/theme/DesignTokens.kt): a detail page opens from a row and the two sit side by side, @@ -145,6 +76,70 @@ private val QuietText: Color get() = MembyQuietText */ private val MediaTypeMarkSize = 13.dp +/** + * Everything [MediaCard] needs to render a poster or landscape row card, with no + * [BaseItem] field reads of its own. Mirrors the split [ResumableMediaCardModel] already + * makes for [com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard]: the row-level card (this file's [PosterCard], + * [PosterGridCard] and [LandscapeCard] in the sibling file) still takes a [BaseItem] — a home + * row is a `List` — and only the innermost renderer is decoupled from the wire + * model. The artwork URL is resolved by the caller rather than carried on the model: sizing + * it needs the card's width and the local density, which are known one level up and would + * otherwise have to be threaded back onto every field of a data class just to get here. + */ +internal data class MediaCardUiModel( + val id: String, + val name: String, + val subtitle: String, + val showSecondaryMetadata: Boolean, + val contentDescription: String, + val played: Boolean, + val favourite: Boolean, + val progress: Float, + val isSchedule: Boolean, + val scheduleAvailability: String, + val scheduleStatusLabel: String?, + val lifecycleStatus: String, + val lifecycleLabel: String?, + val airingLabel: String?, + val typeMark: Pair?, +) + +/** + * @param showProgress whether a "Resume at …" subtitle should replace the default one when + * the item has a saved position — a row-level choice, not a fact about the item. + */ +internal fun BaseItem.toMediaCardUiModel( + showProgress: Boolean, + showSecondaryMetadata: Boolean, + showWatchedEpisodeCount: Boolean, + showMediaTypeIcon: Boolean, +): MediaCardUiModel { + val position = userData?.playbackPositionTicks ?: 0L + val runtime = runTimeTicks ?: 0L + val progress = if (runtime > 0L) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f + return MediaCardUiModel( + id = id, + name = name, + subtitle = if (showSecondaryMetadata) { + cardSubtitle(this, showProgress, position, showWatchedEpisodeCount) + } else { + "" + }, + showSecondaryMetadata = showSecondaryMetadata, + contentDescription = cardDescription(this, progress, showWatchedEpisodeCount, showMediaTypeIcon), + played = userData?.played == true, + favourite = isFavorite, + progress = progress, + isSchedule = isSchedule, + scheduleAvailability = membyAvailability.orEmpty(), + scheduleStatusLabel = if (isSchedule) scheduleStatusBadgeLabel(membyAvailability.orEmpty()) else null, + lifecycleStatus = membyLifecycle.orEmpty(), + lifecycleLabel = if (isSchedule) lifecycleBadgeLabel(this) else null, + airingLabel = airingBadgeLabel(this), + typeMark = if (showMediaTypeIcon) mediaTypeMark(this) else null, + ) +} + @Composable fun PosterCard( item: BaseItem, @@ -164,9 +159,12 @@ fun PosterCard( else -> 7 } val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp) + val model = remember(item, showProgress, showSecondaryMetadata, showWatchedEpisodeCount) { + item.toMediaCardUiModel(showProgress, showSecondaryMetadata, showWatchedEpisodeCount, showMediaTypeIcon = false) + } MediaCard( - item, width, 2f / 3f, preferPrimary = true, showProgress = showProgress, - showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, + model, item, width, 2f / 3f, preferPrimary = true, showProgress, + onFocused, onClick, onLongClick, modifier, ) } @@ -188,34 +186,33 @@ fun PosterGridCard( showWatchedEpisodeCount: Boolean = false, showMediaTypeIcon: Boolean = false, ) { + val model = remember(item, showWatchedEpisodeCount, showMediaTypeIcon) { + item.toMediaCardUiModel( + showProgress = false, + showSecondaryMetadata = true, + showWatchedEpisodeCount, + showMediaTypeIcon, + ) + } MediaCard( - item, width, 2f / 3f, preferPrimary = true, showProgress = false, - showSecondaryMetadata = true, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier, - showMediaTypeIcon = showMediaTypeIcon, + model, item, width, 2f / 3f, preferPrimary = true, showProgress = false, + onFocused, onClick, onLongClick, modifier, ) } +/** + * Resolves the artwork URL and scale for [item] at [widthPx], preferring the primary + * (poster) image or the backdrop depending on [preferPrimary] and falling back to whichever + * of the two the item actually has. + */ @Composable - -internal fun MediaCard( +private fun rememberMediaCardArtwork( item: BaseItem, - width: Dp, - aspectRatio: Float, + widthPx: Int, preferPrimary: Boolean, - showProgress: Boolean, - showSecondaryMetadata: Boolean, - showWatchedEpisodeCount: Boolean, - onFocused: () -> Unit, - onClick: () -> Unit, - onLongClick: () -> Unit, - modifier: Modifier, - showMediaTypeIcon: Boolean = false, -) { +): Pair? { val repo = ServiceLocator.repository - val density = LocalDensity.current - val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720) - val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1) - val image = remember(item.id, widthPx, preferPrimary) { + return remember(item.id, widthPx, preferPrimary) { if (preferPrimary) { repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit } ?: repo.backdropUrl(item, widthPx)?.let { it to ContentScale.Crop } @@ -224,12 +221,28 @@ internal fun MediaCard( ?: repo.primaryUrl(item, widthPx)?.let { it to ContentScale.Fit } } } +} + +@Composable +internal fun MediaCard( + model: MediaCardUiModel, + item: BaseItem, + width: Dp, + aspectRatio: Float, + preferPrimary: Boolean, + showProgress: Boolean, + onFocused: () -> Unit, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier, +) { + val density = LocalDensity.current + val widthPx = with(density) { width.roundToPx() }.coerceIn(180, 720) + val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1) + val image = rememberMediaCardArtwork(item, widthPx, preferPrimary) val imageUrl = image?.first - val position = item.userData?.playbackPositionTicks ?: 0L - val runtime = item.runTimeTicks ?: 0L - val progress = if (runtime > 0L) (position.toFloat() / runtime).coerceIn(0f, 1f) else 0f - var failed by remember(item.id, imageUrl) { mutableStateOf(false) } - var loading by remember(item.id, imageUrl) { mutableStateOf(imageUrl != null) } + var failed by remember(model.id, imageUrl) { mutableStateOf(false) } + var loading by remember(model.id, imageUrl) { mutableStateOf(imageUrl != null) } val context = LocalContext.current val imageRequest = remember(imageUrl, widthPx, heightPx, context) { imageUrl?.let { @@ -245,7 +258,7 @@ internal fun MediaCard( onFocused = onFocused, onClick = onClick, onLongClick = onLongClick, - contentDescription = cardDescription(item, progress, showWatchedEpisodeCount, showMediaTypeIcon), + contentDescription = model.contentDescription, modifier = modifier.width(width), ) { focused -> Column { @@ -298,27 +311,27 @@ internal fun MediaCard( modifier = Modifier.size(30.dp), ) } - if (showProgress && progress > 0f) { + if (showProgress && model.progress > 0f) { Box( Modifier.align(Alignment.BottomCenter).fillMaxWidth().height(5.dp) .background(Color.Black.copy(alpha = 0.65f)), ) { - Box(Modifier.fillMaxWidth(progress).height(5.dp).background(EmbyGreen)) + Box(Modifier.fillMaxWidth(model.progress).height(5.dp).background(EmbyGreen)) } } - if (item.userData?.played == true || item.isFavorite) { + if (model.played || model.favourite) { Column( modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - if (item.userData?.played == true) { + if (model.played) { MediaStatusIcon( icon = MembyIcon.CheckCircle.mark, description = "Watched", tint = EmbyGreen, ) } - if (item.isFavorite) { + if (model.favourite) { MediaStatusIcon( icon = MembyIcon.Favourite.mark, description = "Favourite", @@ -327,10 +340,10 @@ internal fun MediaCard( } } } - if (item.isSchedule) { - scheduleStatusBadgeLabel(item.membyAvailability.orEmpty())?.let { + if (model.isSchedule) { + model.scheduleStatusLabel?.let { ScheduleStatusBadge( - status = item.membyAvailability.orEmpty(), + status = model.scheduleAvailability, label = it, modifier = Modifier.align(Alignment.TopEnd).padding(8.dp), ) @@ -338,18 +351,17 @@ internal fun MediaCard( // Bottom-left, because the two top corners already carry the air day and // this episode's availability, and the progress bar owns the bottom edge // only on cards that can be resumed — which a schedule card cannot. - lifecycleBadgeLabel(item)?.let { + model.lifecycleLabel?.let { LifecycleBadge( - status = item.membyLifecycle.orEmpty(), + status = model.lifecycleStatus, label = it, modifier = Modifier.align(Alignment.BottomStart).padding(8.dp), ) } } - val airingLabel = airingBadgeLabel(item) - if (airingLabel != null) { + if (model.airingLabel != null) { MediaBadge( - airingLabel, + model.airingLabel, modifier = Modifier.align(Alignment.TopStart).padding(8.dp), ) } @@ -358,7 +370,7 @@ internal fun MediaCard( } } Text( - item.name, + model.name, color = if (focused) Color.White else MembyOnSurface, fontSize = 14.sp, fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold, @@ -366,18 +378,17 @@ internal fun MediaCard( overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 9.dp).fillMaxWidth(), ) - if (showSecondaryMetadata) { + if (model.showSecondaryMetadata) { // The media type sits *in* the metadata line rather than over the poster: // it is informational, like the year and the runtime beside it, and the // artwork is the one thing on the card worth looking at. The row is // vertically centred and the mark is smaller than the line it sits on, so // it cannot make the card taller than one without it. - val typeMark = if (showMediaTypeIcon) mediaTypeMark(item) else null Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp).fillMaxWidth(), ) { - typeMark?.let { (icon, _) -> + model.typeMark?.let { (icon, _) -> Icon( icon, // The card's own description already names the type; a second @@ -389,7 +400,7 @@ internal fun MediaCard( Spacer(Modifier.width(5.dp)) } Text( - cardSubtitle(item, showProgress, position, showWatchedEpisodeCount), + model.subtitle, color = QuietText, fontSize = 12.sp, fontWeight = FontWeight.Medium, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt index 622da93..4728332 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeNavigation.kt @@ -78,6 +78,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.onLongClick import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -134,9 +135,11 @@ private val EmbyGreen: Color get() = MembyAccent private val RailSurface: Color get() = MembySurface.copy(alpha = 0.95f) private val MutedText: Color get() = MembyMutedText private val QuietText: Color get() = MembyQuietText -internal val TvRailCollapsedWidth = 54.dp +internal val TvRailCollapsedWidth = 104.dp internal val TvRailExpandedWidth = 184.dp internal val HomeContentHorizontalInset = 48.dp +private val TvRailCollapsedItemWidth = 72.dp +private val TvRailHomeFootprint = 54.dp /** * The rail's order, which is this declaration order — see [navigationRailItems], the only @@ -230,16 +233,19 @@ fun TvNavigationRail( ) Box( modifier = modifier - .width(TvRailCollapsedWidth) + // Home content begins after this slim footprint, then applies its own 48dp + // reading inset. Together they meet the 104dp rail edge without preserving + // a second, invisible gutter from the old sidebar layout. + .width(TvRailHomeFootprint) .fillMaxHeight() .zIndex(8f), ) { Column( modifier = Modifier - // The parent deliberately reports only the collapsed footprint to the - // home Row. requiredWidth lets the focused surface draw outward without - // remeasuring gallery cards or clipping their labels to 54dp. - // Only width may escape the collapsed 54dp footprint. Unbinding height + // The parent deliberately reports only the Home footprint to the Row. + // The unbounded width lets the collapsed and focused surfaces draw outward + // without remeasuring gallery cards or clipping their labels to 104dp. + // Only width may escape the collapsed 104dp footprint. Unbinding height // here would make fillMaxHeight lose the screen constraint and stop the // rail surface at its final child (the version label). .wrapContentWidth(Alignment.Start, unbounded = true) @@ -264,12 +270,16 @@ fun TvNavigationRail( } .focusGroup() .padding(horizontal = 5.dp, vertical = 15.dp), - horizontalAlignment = Alignment.Start, + horizontalAlignment = Alignment.CenterHorizontally, ) { Row( - modifier = Modifier.height(42.dp).padding(horizontal = 6.dp), + modifier = Modifier.fillMaxWidth().height(42.dp).padding(horizontal = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = if (expanded) { + Arrangement.spacedBy(12.dp) + } else { + Arrangement.Center + }, ) { // Memby's own icon rather than Emby's mark: the rail is Memby's chrome, and // the one place the app names itself should not be somebody else's logo. @@ -382,7 +392,10 @@ fun TvNavigationRail( fontSize = if (expanded) 10.sp else 8.sp, fontWeight = FontWeight.Medium, maxLines = 1, - modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp), + textAlign = if (expanded) TextAlign.Start else TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (expanded) 8.dp else 4.dp), ) } } @@ -856,7 +869,10 @@ fun ExpandableNavigationItem( ) Row( modifier = modifier - .fillMaxWidth() + .then( + if (expanded) Modifier.fillMaxWidth() + else Modifier.width(TvRailCollapsedItemWidth), + ) .height(44.dp) .onFocusChanged { focused = it.isFocused @@ -874,9 +890,13 @@ fun ExpandableNavigationItem( } } } - .padding(horizontal = 8.dp), + .padding(horizontal = if (expanded) 8.dp else 0.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = if (expanded) { + Arrangement.spacedBy(12.dp) + } else { + Arrangement.Center + }, ) { Box(Modifier.width(28.dp), contentAlignment = Alignment.Center) { if (avatarInitials != null) { @@ -909,7 +929,7 @@ fun ExpandableNavigationItem( ) } // Over the icon rather than after the label: the rail spends most of its life - // collapsed to 54dp, where there is no label to sit beside. + // collapsed, where there is no label to sit beside. if (badge != null) { Text( badge, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt index 554ddc3..178c3f7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/home/HomeRow.kt @@ -2,39 +2,24 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.ui.theme.MembyIcon import com.ponzischeme89.memby.ui.theme.mark -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.focusGroup -import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -42,80 +27,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.shadow import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.layout import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.type -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.onLongClick -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex import androidx.tv.material3.Icon import androidx.tv.material3.Text -import coil.compose.AsyncImage -import coil.request.ImageRequest -import com.ponzischeme89.memby.ServiceLocator -import com.ponzischeme89.memby.R -import com.ponzischeme89.memby.BuildConfig -import com.ponzischeme89.memby.data.EmbyProfile import com.ponzischeme89.memby.data.model.BaseItem -import com.ponzischeme89.memby.data.model.MembyViewer -import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel -import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH -import com.ponzischeme89.memby.ui.detail.channelLabel -import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel -import com.ponzischeme89.memby.ui.detail.formatRuntime -import com.ponzischeme89.memby.ui.theme.FactSeparator -import com.ponzischeme89.memby.ui.theme.MembyAccent -import com.ponzischeme89.memby.ui.theme.MembyAccentInk -import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyChipCorner -import com.ponzischeme89.memby.ui.theme.MembyControlSurface -import com.ponzischeme89.memby.ui.theme.MembyControlSurfaceRaised -import com.ponzischeme89.memby.ui.theme.MembyMutedText import com.ponzischeme89.memby.ui.theme.MembyOnSurface -import com.ponzischeme89.memby.ui.theme.MembyPanelCorner import com.ponzischeme89.memby.ui.theme.MembyQuietText -import com.ponzischeme89.memby.ui.theme.MembySurface -import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised -import com.ponzischeme89.memby.ui.theme.ValueSeparator -import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig -import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels -import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig -import java.util.Locale +import com.ponzischeme89.memby.ui.components.media.ContinueWatchingCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.milliseconds diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt index 3436202..0b87fde 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/NavigationRailTest.kt @@ -1,11 +1,17 @@ package com.ponzischeme89.memby.ui +import androidx.compose.ui.unit.dp import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class NavigationRailTest { + @Test + fun `collapsed rail stays within the slim TV target`() { + assertTrue(TvRailCollapsedWidth in 96.dp..112.dp) + } + @Test fun `user switcher is pinned directly above home`() { val items = navigationRailItems(calendarEnabled = false) diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt index 336d0b7..4daf9c1 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/genre/GenreRailScreenshotTest.kt @@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.tv.material3.Text import com.github.takahirom.roborazzi.captureRoboImage +import com.ponzischeme89.memby.ui.TvRailCollapsedWidth import com.ponzischeme89.memby.ui.theme.MembyCardCorner import com.ponzischeme89.memby.ui.theme.MembyControlSurface import com.ponzischeme89.memby.ui.theme.MembyPalette @@ -55,9 +56,9 @@ import org.robolectric.annotation.GraphicsMode * metres, and getting it wrong in either direction (invisible, or a second black column * that makes the screen look like two applications side by side) looks fine in code. * - * The launcher rail is a stand-in of its real 54dp collapsed width rather than the real + * The launcher rail is a stand-in of its real collapsed width rather than the real * component, which would want a service locator; what is being judged is the boundary, - * and 54dp of near-black is exactly what sits there. + * and the launcher's near-black strip is exactly what sits there. */ @RunWith(RobolectricTestRunner::class) @GraphicsMode(GraphicsMode.Mode.NATIVE) @@ -113,8 +114,8 @@ class GenreRailScreenshotTest { val categories = genreCategoryTabs(ALL_MEDIA_ITEM_TYPE) compose.setContent { Row(Modifier.fillMaxSize().background(MembySurface)) { - // The launcher's own rail, at its real collapsed footprint. - Box(Modifier.width(54.dp).fillMaxSize().background(MembySurface)) + // The launcher's own rail, at its real collapsed surface width. + Box(Modifier.width(TvRailCollapsedWidth).fillMaxSize().background(MembySurface)) GenreRail( categories = categories, activeCategoryId = active,