Better video grid, poster images, & home page images (#238)

- General video library grid now shows 16:9 cards
- Ensures the posters on Movie/TV show grid pages fill the bounds (TVDB
posters are often very slightly wider than 2x3)
- Always crossfade home page backdrop image

Related to #66
This commit is contained in:
damontecres 2025-11-17 13:44:03 -05:00 committed by GitHub
parent 4d34c977c8
commit 98fde1b65e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 128 additions and 32 deletions

View file

@ -0,0 +1,26 @@
package com.github.damontecres.wholphin.ui
import coil3.request.ImageResult
import coil3.request.SuccessResult
import coil3.transition.CrossfadeTransition
import coil3.transition.Transition
import coil3.transition.TransitionTarget
import kotlin.time.Duration
import kotlin.time.DurationUnit
/**
* Similar to [coil3.transition.CrossfadeTransition.Factory] but always cross fades even if loading from memory
*/
class CrossFadeFactory(
val duration: Duration,
) : Transition.Factory {
override fun create(
target: TransitionTarget,
result: ImageResult,
): Transition =
if (result is SuccessResult) {
CrossfadeTransition(target, result, duration.toInt(DurationUnit.MILLISECONDS), false)
} else {
Transition.Factory.NONE.create(target, result)
}
}

View file

@ -17,6 +17,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.layout.ContentScale
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -40,6 +41,7 @@ fun GridCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageAspectRatio: Float = AspectRatios.TALL, imageAspectRatio: Float = AspectRatios.TALL,
imageContentScale: ContentScale = ContentScale.Fit,
) { ) {
val dto = item?.data val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState() val focused by interactionSource.collectIsFocusedAsState()
@ -85,6 +87,7 @@ fun GridCard(
unwatchedCount = dto?.userData?.unplayedItemCount ?: -1, unwatchedCount = dto?.userData?.unplayedItemCount ?: -1,
watchedPercent = dto?.userData?.playedPercentage, watchedPercent = dto?.userData?.playedPercentage,
useFallbackText = false, useFallbackText = false,
contentScale = imageContentScale,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

View file

@ -52,6 +52,7 @@ fun ItemCardImage(
watchedPercent: Double?, watchedPercent: Double?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
useFallbackText: Boolean = true, useFallbackText: Boolean = true,
contentScale: ContentScale = ContentScale.Fit,
) { ) {
var imageError by remember { mutableStateOf(false) } var imageError by remember { mutableStateOf(false) }
Box(modifier = modifier) { Box(modifier = modifier) {
@ -59,7 +60,7 @@ fun ItemCardImage(
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = name, contentDescription = name,
contentScale = ContentScale.Fit, contentScale = contentScale,
alignment = Alignment.Center, alignment = Alignment.Center,
onError = { onError = {
logCoilError(imageUrl, it.result) logCoilError(imageUrl, it.result)

View file

@ -20,9 +20,11 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
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.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
@ -41,6 +43,7 @@ import com.github.damontecres.wholphin.services.FavoriteWatchManager
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel
import com.github.damontecres.wholphin.ui.data.SortAndDirection import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.detail.CardGrid import com.github.damontecres.wholphin.ui.detail.CardGrid
@ -310,6 +313,7 @@ fun CollectionFolderGrid(
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
) = CollectionFolderGrid( ) = CollectionFolderGrid(
preferences, preferences,
itemId.toServerString(), itemId.toServerString(),
@ -321,6 +325,7 @@ fun CollectionFolderGrid(
initialSortAndDirection = initialSortAndDirection, initialSortAndDirection = initialSortAndDirection,
showTitle = showTitle, showTitle = showTitle,
positionCallback = positionCallback, positionCallback = positionCallback,
params = params,
) )
@Composable @Composable
@ -337,6 +342,7 @@ fun CollectionFolderGrid(
initialSortAndDirection: SortAndDirection? = null, initialSortAndDirection: SortAndDirection? = null,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
) { ) {
val context = LocalContext.current val context = LocalContext.current
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
@ -376,6 +382,7 @@ fun CollectionFolderGrid(
sortOptions = sortOptions, sortOptions = sortOptions,
positionCallback = positionCallback, positionCallback = positionCallback,
letterPosition = { viewModel.positionOfLetter(it) ?: -1 }, letterPosition = { viewModel.positionOfLetter(it) ?: -1 },
params = params,
) )
} }
} }
@ -445,6 +452,7 @@ fun CollectionFolderGridContent(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showTitle: Boolean = true, showTitle: Boolean = true,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
params: CollectionFolderGridParameters = CollectionFolderGridParameters(),
) { ) {
val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection) val title = item?.name ?: item?.data?.collectionType?.name ?: stringResource(R.string.collection)
@ -493,6 +501,9 @@ fun CollectionFolderGridContent(
showHeader = position < columns showHeader = position < columns
positionCallback?.invoke(columns, position) positionCallback?.invoke(columns, position)
}, },
cardContent = params.cardContent,
columns = params.columns,
spacing = params.spacing,
) )
} }
} }
@ -501,3 +512,22 @@ data class PositionItem(
val position: Int, val position: Int,
val item: BaseItem, val item: BaseItem,
) )
data class CollectionFolderGridParameters(
val columns: Int = 6,
val spacing: Dp = 16.dp,
val cardContent: @Composable (
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
modifier = mod,
)
},
)

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@ -91,6 +92,7 @@ fun CardGrid(
item = item, item = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
modifier = mod, modifier = mod,
) )
}, },

View file

@ -7,12 +7,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.data.VideoSortOptions import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import java.util.UUID import java.util.UUID
@ -28,6 +32,21 @@ fun CollectionFolderGeneric(
preferencesViewModel: PreferencesViewModel = hiltViewModel(), preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) { ) {
var showHeader by remember { mutableStateOf(true) } var showHeader by remember { mutableStateOf(true) }
val params =
CollectionFolderGridParameters(
columns = 4,
spacing = 24.dp,
cardContent = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.Crop,
imageAspectRatio = AspectRatios.WIDE,
modifier = mod,
)
},
)
CollectionFolderGrid( CollectionFolderGrid(
preferences = preferences, preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) }, onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
@ -42,5 +61,6 @@ fun CollectionFolderGeneric(
positionCallback = { columns, position -> positionCallback = { columns, position ->
showHeader = position < columns showHeader = position < columns
}, },
params = params,
) )
} }

View file

@ -44,11 +44,14 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
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.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios 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.CrossFadeFactory
import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCard
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
@ -78,6 +81,7 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaType import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
@Composable @Composable
fun HomePage( fun HomePage(
@ -198,6 +202,7 @@ fun HomePageContent(
onFocusPosition: ((RowColumn) -> Unit)? = null, onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null, loadingState: LoadingState? = null,
) { ) {
val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val firstRow = val firstRow =
remember { remember {
@ -239,38 +244,47 @@ fun HomePageContent(
LaunchedEffect(position) { LaunchedEffect(position) {
listState.animateScrollToItem(position.row) listState.animateScrollToItem(position.row)
} }
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedItem) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedItem?.backdropImageUrl
}
Box(modifier = modifier) { Box(modifier = modifier) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) { val gradientColor = MaterialTheme.colorScheme.background
val gradientColor = MaterialTheme.colorScheme.background AsyncImage(
AsyncImage( model =
model = focusedItem?.backdropImageUrl, ImageRequest
contentDescription = null, .Builder(context)
contentScale = ContentScale.Fit, .data(backdropImageUrl)
alignment = Alignment.TopEnd, .transitionFactory(CrossFadeFactory(250.milliseconds))
modifier = .build(),
Modifier contentDescription = null,
.fillMaxHeight(.7f) contentScale = ContentScale.Fit,
.fillMaxWidth(.7f) alignment = Alignment.TopEnd,
.alpha(.75f) modifier =
.align(Alignment.TopEnd) Modifier
.drawWithContent { .fillMaxHeight(.7f)
drawContent() .fillMaxWidth(.7f)
drawRect( .alpha(.75f)
Brush.verticalGradient( .align(Alignment.TopEnd)
colors = listOf(Color.Transparent, gradientColor), .drawWithContent {
startY = size.height * .33f, drawContent()
), drawRect(
) Brush.verticalGradient(
drawRect( colors = listOf(Color.Transparent, gradientColor),
Brush.horizontalGradient( startY = size.height * .33f,
colors = listOf(gradientColor, Color.Transparent), ),
startX = 0f, )
endX = size.width * .5f, drawRect(
), Brush.horizontalGradient(
) colors = listOf(gradientColor, Color.Transparent),
}, startX = 0f,
) endX = size.width * .5f,
} ),
)
},
)
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader( HomePageHeader(