0.2.95 - Refactor

This commit is contained in:
ponzischeme89
2026-08-21 22:07:02 +12:00
parent 02247a6287
commit 32019f2e20
11 changed files with 190 additions and 312 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.94"
val defaultVersionName = "0.2.95"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -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
@@ -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
@@ -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<UserNotification>) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
@SuppressLint("ModifierParameter") modifier: Modifier = Modifier,
) {
var tab by remember { mutableStateOf(AlertsTab.INBOX) }
var page by remember { mutableStateOf(0) }
@@ -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(
@@ -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,
)
}
@@ -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<BaseItem>` — 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<ImageVector, String>?,
)
/**
* @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<String, ContentScale>? {
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,
@@ -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,
@@ -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
@@ -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)
@@ -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,