From 693398536e2bf7a86d797313264cfcea6adea050 Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:46:57 -0500 Subject: [PATCH] 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 --- .../wholphin/data/model/BaseItem.kt | 12 +- .../wholphin/services/ImageUrlService.kt | 2 +- .../wholphin/ui/cards/GenreCard.kt | 127 ++++++++++++++++++ .../wholphin/ui/components/GenreCardGrid.kt | 102 ++++++++++++-- .../wholphin/ui/detail/CardGrid.kt | 25 ++-- .../wholphin/ui/setup/ServerList.kt | 8 +- 6 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index 7abdc890..e5cd53bc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt index 821794e3..164035e7 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/ImageUrlService.kt @@ -31,7 +31,7 @@ class ImageUrlService imageType: ImageType, fillWidth: Int? = null, fillHeight: Int? = null, - ): String? = + ): String = when (imageType) { ImageType.BACKDROP, ImageType.LOGO, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt new file mode 100644 index 00000000..43d160e3 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/GenreCard.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index 33900e2f..f4fea70c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -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.Pending) - val genres = MutableLiveData>(listOf()) + val genres = MutableLiveData>(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() + val genreToUrl = ConcurrentHashMap() + 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, ) }, ) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt index eeb16031..ba74641a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CardGrid.kt @@ -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, - onClickItem: (Int, BaseItem) -> Unit, - onLongClickItem: (Int, BaseItem) -> Unit, - onClickPlay: (Int, BaseItem) -> Unit, +fun CardGrid( + pager: List, + 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() diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt index 4ab990de..0d7d3c6e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/setup/ServerList.kt @@ -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()