Option to show title below cards

This commit is contained in:
Damontecres 2026-02-05 12:57:34 -05:00
parent 99b15f20c0
commit 1828e94fae
No known key found for this signature in database
4 changed files with 230 additions and 100 deletions

View file

@ -1,15 +1,19 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@ -24,6 +28,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -41,6 +46,7 @@ import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.enableMarquee
import org.jellyfin.sdk.model.api.ImageType
/**
@ -184,3 +190,82 @@ fun BannerCard(
}
}
}
@Composable
fun BannerCardWithTitle(
title: String?,
subtitle: String?,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
cornerText: String? = null,
played: Boolean = false,
favorite: Boolean = false,
playPercent: Double = 0.0,
cardHeight: Dp = 120.dp,
aspectRatio: Float = AspectRatios.WIDE,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageType: ImageType = ImageType.PRIMARY,
imageContentScale: ContentScale = ContentScale.FillBounds,
) {
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = cardHeight * aspectRationToUse
Column(
verticalArrangement = Arrangement.spacedBy(spaceBetween),
modifier = modifier.width(width),
) {
BannerCard(
name = null,
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = Modifier,
cornerText = cornerText,
played = played,
favorite = favorite,
playPercent = playPercent,
cardHeight = cardHeight,
aspectRatio = aspectRatio,
interactionSource = interactionSource,
imageType = imageType,
imageContentScale = imageContentScale,
)
Column(
verticalArrangement = Arrangement.spacedBy(0.dp),
modifier =
Modifier
.padding(bottom = spaceBelow)
.fillMaxWidth(),
) {
Text(
text = title ?: "",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
Text(
text = subtitle ?: "",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Normal,
maxLines = 1,
textAlign = TextAlign.Center,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp)
.enableMarquee(focusedAfterDelay),
)
}
}
}

View file

@ -16,7 +16,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
@ -126,21 +125,7 @@ fun SeasonCard(
val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) }
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / aspectRationToUse)
@ -212,3 +197,22 @@ fun SeasonCard(
}
}
}
/**
* Returns a [androidx.compose.runtime.State] which represents if the item has been focused for a while
*/
@Composable
fun rememberFocusedAfterDelay(interactionSource: MutableInteractionSource): androidx.compose.runtime.State<Boolean> {
val focused by interactionSource.collectIsFocusedAsState()
val state = remember { mutableStateOf(false) }
LaunchedEffect(focused) {
if (!focused) {
state.value = false
return@LaunchedEffect
}
delay(500L)
state.value = true
}
return state
}

View file

@ -46,9 +46,11 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
@ -330,91 +332,41 @@ fun HomePageContent(
.animateItem(),
horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick ->
val mod =
cardModifier
.onFocusChanged {
if (it.isFocused) {
position = RowColumn(rowIndex, index)
}
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
)
}
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
return@onKeyEvent false
}
when (item?.type) {
BaseItemKind.GENRE -> {
GenreCard(
genreId = item.id,
name = item.name,
imageUrl = item.imageUrlOverride,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod.height(viewOptions.heightDp.dp),
)
}
else -> {
val imageType =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeImageType.imageType
} else {
viewOptions.imageType.imageType
HomePageCardContent(
index = index,
item = item,
onClick = onClick,
onLongClick = onLongClick,
viewOptions = viewOptions,
modifier =
cardModifier
.onFocusChanged {
if (it.isFocused) {
position = RowColumn(rowIndex, index)
}
}
val ratio =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeAspectRatio.ratio
} else {
viewOptions.aspectRatio.ratio
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
)
}
}
val scale =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeContentScale.scale
} else {
viewOptions.contentScale.scale
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
}
BannerCard(
name = item?.data?.seriesName ?: item?.name,
item = item,
aspectRatio = ratio,
imageType = imageType,
imageContentScale = scale,
cornerText = item?.ui?.episdodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
interactionSource = null,
cardHeight = viewOptions.heightDp.dp,
)
}
}
return@onKeyEvent false
},
)
},
)
}
@ -516,3 +468,92 @@ fun HomePageHeader(
}
}
}
@Composable
fun HomePageCardContent(
index: Int,
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
viewOptions: HomeRowViewOptions,
modifier: Modifier,
) {
when (item?.type) {
BaseItemKind.GENRE -> {
GenreCard(
genreId = item.id,
name = item.name,
imageUrl = item.imageUrlOverride,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier.height(viewOptions.heightDp.dp),
)
}
else -> {
val imageType =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeImageType.imageType
} else {
viewOptions.imageType.imageType
}
}
val ratio =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeAspectRatio.ratio
} else {
viewOptions.aspectRatio.ratio
}
}
val scale =
remember(item, viewOptions) {
if (item?.type == BaseItemKind.EPISODE) {
viewOptions.episodeContentScale.scale
} else {
viewOptions.contentScale.scale
}
}
if (viewOptions.showTitles) {
BannerCardWithTitle(
title = item?.title,
subtitle = item?.subtitle,
item = item,
aspectRatio = ratio,
imageType = imageType,
imageContentScale = scale,
cornerText = item?.ui?.episdodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
cardHeight = viewOptions.heightDp.dp,
)
} else {
BannerCard(
name = item?.data?.seriesName ?: item?.name,
item = item,
aspectRatio = ratio,
imageType = imageType,
imageContentScale = scale,
cornerText = item?.ui?.episdodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = null,
cardHeight = viewOptions.heightDp.dp,
)
}
}
}
}

View file

@ -238,11 +238,10 @@ internal object Options {
listOf(
ViewOptionsCardHeight,
ViewOptionsSpacing,
ViewOptionsShowTitles,
ViewOptionsImageType,
ViewOptionsAspectRatio,
ViewOptionsContentScale,
// TODO
// ViewOptionsShowTitles,
ViewOptionsUseSeries,
),
),
@ -265,6 +264,7 @@ internal object Options {
listOf(
ViewOptionsCardHeight,
ViewOptionsSpacing,
ViewOptionsShowTitles,
ViewOptionsImageType,
ViewOptionsAspectRatio,
ViewOptionsContentScale,