mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
4d34c977c8
commit
98fde1b65e
7 changed files with 128 additions and 32 deletions
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue