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 package com.github.damontecres.wholphin.ui.cards
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.platform.LocalDensity
import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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.Cards
import com.github.damontecres.wholphin.ui.FontAwesome import com.github.damontecres.wholphin.ui.FontAwesome
import com.github.damontecres.wholphin.ui.LocalImageUrlService import com.github.damontecres.wholphin.ui.LocalImageUrlService
import com.github.damontecres.wholphin.ui.enableMarquee
import org.jellyfin.sdk.model.api.ImageType 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.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@ -126,21 +125,7 @@ fun SeasonCard(
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp) val spaceBetween by animateDpAsState(if (focused) 12.dp else 4.dp)
val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp) val spaceBelow by animateDpAsState(if (focused) 4.dp else 12.dp)
var focusedAfterDelay by remember { mutableStateOf(false) } val focusedAfterDelay by rememberFocusedAfterDelay(interactionSource)
val hideOverlayDelay = 500L
if (focused) {
LaunchedEffect(Unit) {
delay(hideOverlayDelay)
if (focused) {
focusedAfterDelay = true
} else {
focusedAfterDelay = false
}
}
} else {
focusedAfterDelay = false
}
val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN) val aspectRationToUse = aspectRatio.coerceAtLeast(AspectRatios.MIN)
val width = imageHeight * aspectRationToUse val width = imageHeight * aspectRationToUse
val height = imageWidth * (1f / 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 androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard 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.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
@ -330,91 +332,41 @@ fun HomePageContent(
.animateItem(), .animateItem(),
horizontalPadding = viewOptions.spacing.dp, horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
val mod = HomePageCardContent(
cardModifier index = index,
.onFocusChanged { item = item,
if (it.isFocused) { onClick = onClick,
position = RowColumn(rowIndex, index) onLongClick = onLongClick,
} viewOptions = viewOptions,
if (it.isFocused && onFocusPosition != null) { modifier =
val nonEmptyRowBefore = cardModifier
homeRows .onFocusChanged {
.subList(0, rowIndex) if (it.isFocused) {
.count { position = RowColumn(rowIndex, index)
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
} }
} if (it.isFocused && onFocusPosition != null) {
val ratio = val nonEmptyRowBefore =
remember(item, viewOptions) { homeRows
if (item?.type == BaseItemKind.EPISODE) { .subList(0, rowIndex)
viewOptions.episodeAspectRatio.ratio .count {
} else { it is HomeRowLoadingState.Success && it.items.isEmpty()
viewOptions.aspectRatio.ratio }
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
)
} }
} }.onKeyEvent {
val scale = if (isPlayKeyUp(it) && item?.type?.playable == true) {
remember(item, viewOptions) { Timber.v("Clicked play on ${item.id}")
if (item?.type == BaseItemKind.EPISODE) { onClickPlay.invoke(position, item)
viewOptions.episodeContentScale.scale return@onKeyEvent true
} else {
viewOptions.contentScale.scale
} }
} return@onKeyEvent false
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,
)
}
}
}, },
) )
} }
@ -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( listOf(
ViewOptionsCardHeight, ViewOptionsCardHeight,
ViewOptionsSpacing, ViewOptionsSpacing,
ViewOptionsShowTitles,
ViewOptionsImageType, ViewOptionsImageType,
ViewOptionsAspectRatio, ViewOptionsAspectRatio,
ViewOptionsContentScale, ViewOptionsContentScale,
// TODO
// ViewOptionsShowTitles,
ViewOptionsUseSeries, ViewOptionsUseSeries,
), ),
), ),
@ -265,6 +264,7 @@ internal object Options {
listOf( listOf(
ViewOptionsCardHeight, ViewOptionsCardHeight,
ViewOptionsSpacing, ViewOptionsSpacing,
ViewOptionsShowTitles,
ViewOptionsImageType, ViewOptionsImageType,
ViewOptionsAspectRatio, ViewOptionsAspectRatio,
ViewOptionsContentScale, ViewOptionsContentScale,