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

View file

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

View file

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

View file

@ -7,12 +7,16 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
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.CollectionFolderGridParameters
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import java.util.UUID
@ -28,6 +32,21 @@ fun CollectionFolderGeneric(
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
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(
preferences = preferences,
onClickItem = { _, item -> preferencesViewModel.navigationManager.navigateTo(item.destination()) },
@ -42,5 +61,6 @@ fun CollectionFolderGeneric(
positionCallback = { columns, position ->
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.Text
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transitionFactory
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.AspectRatios
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.ItemRow
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.extensions.ticks
import java.util.UUID
import kotlin.time.Duration.Companion.milliseconds
@Composable
fun HomePage(
@ -198,6 +202,7 @@ fun HomePageContent(
onFocusPosition: ((RowColumn) -> Unit)? = null,
loadingState: LoadingState? = null,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val firstRow =
remember {
@ -239,11 +244,21 @@ fun HomePageContent(
LaunchedEffect(position) {
listState.animateScrollToItem(position.row)
}
var backdropImageUrl by remember { mutableStateOf<String?>(null) }
LaunchedEffect(focusedItem) {
backdropImageUrl = null
delay(150)
backdropImageUrl = focusedItem?.backdropImageUrl
}
Box(modifier = modifier) {
if (focusedItem?.backdropImageUrl.isNotNullOrBlank()) {
val gradientColor = MaterialTheme.colorScheme.background
AsyncImage(
model = focusedItem?.backdropImageUrl,
model =
ImageRequest
.Builder(context)
.data(backdropImageUrl)
.transitionFactory(CrossFadeFactory(250.milliseconds))
.build(),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.TopEnd,
@ -270,7 +285,6 @@ fun HomePageContent(
)
},
)
}
Column(modifier = Modifier.fillMaxSize()) {
HomePageHeader(