Better images for genre grid (#501)

## Description
Overhauls the genre cards for Movies & TV Show libraries.

Now the cards use a semi-transparent, consistent, generated color using
the same logic as in #478. And a random item from the library with that
genre is show in the background. This is heavily inspired from Plex.

If there are not applicable items for the genre, then just the colored
card is shown.

### Related issues
Fixes #490
This commit is contained in:
Ray 2025-12-19 10:46:57 -05:00 committed by GitHub
parent e60748a0d0
commit 693398536e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 252 additions and 24 deletions

View file

@ -1,8 +1,10 @@
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seriesProductionYears
@ -18,8 +20,14 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
) {
val id get() = data.id
) : CardGridItem {
override val id get() = data.id
override val playable: Boolean
get() = type.playable
override val sortName: String
get() = data.sortName ?: data.name ?: ""
val type get() = data.type

View file

@ -31,7 +31,7 @@ class ImageUrlService
imageType: ImageType,
fillWidth: Int? = null,
fillHeight: Int? = null,
): String? =
): String =
when (imageType) {
ImageType.BACKDROP,
ImageType.LOGO,

View file

@ -0,0 +1,127 @@
package com.github.damontecres.wholphin.ui.cards
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Card
import androidx.tv.material3.CardDefaults
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.crossfade
import com.github.damontecres.wholphin.ui.AspectRatios
import com.github.damontecres.wholphin.ui.PreviewTvSpec
import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.setup.rememberIdColor
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
import timber.log.Timber
import java.util.UUID
@Composable
fun GenreCard(
genre: Genre?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
Card(
modifier =
modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
colors =
CardDefaults.colors(
containerColor = Color.Transparent,
),
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
Timber.v("genre image=${genre?.imageUrl}")
if (genre?.imageUrl.isNotNullOrBlank()) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(genre.imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
contentDescription = null,
modifier =
Modifier
.alpha(.6f)
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize(),
)
}
Box(
modifier =
Modifier
.aspectRatio(AspectRatios.WIDE)
.fillMaxSize()
.background(background),
) {
Text(
text = genre?.name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center,
modifier =
Modifier
.padding(16.dp)
.align(Alignment.Center),
)
}
}
}
}
@PreviewTvSpec
@Composable
private fun GenreCardPreview() {
WholphinTheme {
val genre =
Genre(
UUID.randomUUID(),
"Adventure",
null,
Color.Black,
)
GenreCard(
genre = genre,
onClick = {},
onLongClick = {},
modifier = Modifier.width(180.dp),
)
}
}

View file

@ -10,30 +10,43 @@ import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.ImageUrlService
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.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@HiltViewModel
@ -41,11 +54,12 @@ class GenreViewModel
@Inject
constructor(
private val api: ApiClient,
private val imageUrlService: ImageUrlService,
val navigationManager: NavigationManager,
) : ViewModel() {
private lateinit var itemId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<BaseItem?>>(listOf())
val genres = MutableLiveData<List<Genre>>(listOf())
fun init(itemId: UUID) {
loading.value = LoadingState.Loading
@ -56,11 +70,69 @@ class GenreViewModel
parentId = itemId,
fields = SlimItemFields,
)
val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
val genres =
GetGenresRequestHandler
.execute(api, request)
.content.items
.map {
Genre(it.id, it.name ?: "", null, Color.Black)
}
// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
withContext(Dispatchers.Main) {
genres.value = pager
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
// val excludeItemIds = mutableSetOf<UUID>()
val genreToUrl = ConcurrentHashMap<UUID, String>()
val semaphore = Semaphore(4)
genres
.map { genre ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
// excludeItemIds = excludeItemIds,
parentId = itemId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.THUMB),
imageTypeLimit = 1,
includeItemTypes =
listOf(
BaseItemKind.MOVIE,
BaseItemKind.SERIES,
),
genreIds = listOf(genre.id),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
// excludeItemIds.add(item.id)
genreToUrl[genre.id] =
imageUrlService.getItemImageUrl(
item.id,
item.type,
null,
false,
ImageType.THUMB,
)
}
}
}
}.awaitAll()
val genresWithImages =
genres.map {
it.copy(
imageUrl = genreToUrl[it.id],
)
}
this@GenreViewModel.genres.setValueOnMain(genresWithImages)
}
}
@ -78,6 +150,16 @@ class GenreViewModel
}
}
data class Genre(
override val id: UUID,
val name: String,
val imageUrl: String?,
val color: Color,
) : CardGridItem {
override val playable: Boolean = false
override val sortName: String get() = name
}
@Composable
fun GenreCardGrid(
itemId: UUID,
@ -126,13 +208,13 @@ fun GenreCardGrid(
initialPosition = 0,
positionCallback = { columns, position ->
},
cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
GridCard(
item = item,
columns = 4,
cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
GenreCard(
genre = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
imageAspectRatio = 1f,
)
},
)

View file

@ -67,23 +67,29 @@ import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.playback.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.UUID
private const val DEBUG = false
interface CardGridItem {
val id: UUID
val playable: Boolean
val sortName: String
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CardGrid(
pager: List<BaseItem?>,
onClickItem: (Int, BaseItem) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit,
fun <T : CardGridItem> CardGrid(
pager: List<T?>,
onClickItem: (Int, T) -> Unit,
onLongClickItem: (Int, T) -> Unit,
onClickPlay: (Int, T) -> Unit,
letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester,
showJumpButtons: Boolean,
@ -92,13 +98,13 @@ fun CardGrid(
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable (
item: BaseItem?,
item: T?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
item = item as BaseItem?,
onClick = onClick,
onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds,
@ -220,7 +226,7 @@ fun CardGrid(
return@onKeyEvent true
} else if (isPlayKeyUp(it)) {
val item = pager.getOrNull(focusedIndex)
if (item?.type?.playable == true) {
if (item?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(focusedIndex, item)
}
@ -351,7 +357,6 @@ fun CardGrid(
remember(focusedIndex) {
pager
.getOrNull(focusedIndex)
?.data
?.sortName
?.first()
?.uppercaseChar()

View file

@ -179,8 +179,14 @@ fun ServerList(
* Generate a consistent color for a UUID
*/
@Composable
fun rememberIdColor(id: UUID): Color =
fun rememberIdColor(
id: UUID?,
nullColor: Color = MaterialTheme.colorScheme.surfaceVariant,
): Color =
remember(id) {
if (id == null) {
return@remember nullColor
}
// Generate a color based on the server ID hash, fallback to URL hash
val hash = id.hashCode()
val hue = (hash % 360).toFloat()