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 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.detail.series.SeasonEpisodeIds
import com.github.damontecres.wholphin.ui.formatDateTime import com.github.damontecres.wholphin.ui.formatDateTime
import com.github.damontecres.wholphin.ui.nav.Destination 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.seasonEpisode
import com.github.damontecres.wholphin.ui.seasonEpisodePadded import com.github.damontecres.wholphin.ui.seasonEpisodePadded
import com.github.damontecres.wholphin.ui.seriesProductionYears import com.github.damontecres.wholphin.ui.seriesProductionYears
@ -18,8 +20,14 @@ import kotlin.time.Duration
data class BaseItem( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,
) { ) : CardGridItem {
val id get() = data.id 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 val type get() = data.type

View file

@ -31,7 +31,7 @@ class ImageUrlService
imageType: ImageType, imageType: ImageType,
fillWidth: Int? = null, fillWidth: Int? = null,
fillHeight: Int? = null, fillHeight: Int? = null,
): String? = ): String =
when (imageType) { when (imageType) {
ImageType.BACKDROP, ImageType.BACKDROP,
ImageType.LOGO, 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.runtime.remember
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.graphics.Color
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.data.model.GetItemsFilter
import com.github.damontecres.wholphin.services.ImageUrlService
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.cards.GenreCard
import com.github.damontecres.wholphin.ui.detail.CardGrid 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.nav.Destination
import com.github.damontecres.wholphin.ui.setValueOnMain
import com.github.damontecres.wholphin.ui.tryRequestFocus 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.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.LoadingExceptionHandler import com.github.damontecres.wholphin.util.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient 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.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@ -41,11 +54,12 @@ class GenreViewModel
@Inject @Inject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
private val imageUrlService: ImageUrlService,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
) : ViewModel() { ) : ViewModel() {
private lateinit var itemId: UUID private lateinit var itemId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<BaseItem?>>(listOf()) val genres = MutableLiveData<List<Genre>>(listOf())
fun init(itemId: UUID) { fun init(itemId: UUID) {
loading.value = LoadingState.Loading loading.value = LoadingState.Loading
@ -56,11 +70,69 @@ class GenreViewModel
parentId = itemId, parentId = itemId,
fields = SlimItemFields, 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) { withContext(Dispatchers.Main) {
genres.value = pager this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success 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 @Composable
fun GenreCardGrid( fun GenreCardGrid(
itemId: UUID, itemId: UUID,
@ -126,13 +208,13 @@ fun GenreCardGrid(
initialPosition = 0, initialPosition = 0,
positionCallback = { columns, position -> positionCallback = { columns, position ->
}, },
cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> columns = 4,
GridCard( cardContent = { item: Genre?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
item = item, GenreCard(
genre = item,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
modifier = mod, 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.isBackwardButton
import com.github.damontecres.wholphin.ui.playback.isForwardButton import com.github.damontecres.wholphin.ui.playback.isForwardButton
import com.github.damontecres.wholphin.ui.playback.isPlayKeyUp 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.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.ExceptionHandler
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import java.util.UUID
private const val DEBUG = false private const val DEBUG = false
interface CardGridItem {
val id: UUID
val playable: Boolean
val sortName: String
}
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun CardGrid( fun <T : CardGridItem> CardGrid(
pager: List<BaseItem?>, pager: List<T?>,
onClickItem: (Int, BaseItem) -> Unit, onClickItem: (Int, T) -> Unit,
onLongClickItem: (Int, BaseItem) -> Unit, onLongClickItem: (Int, T) -> Unit,
onClickPlay: (Int, BaseItem) -> Unit, onClickPlay: (Int, T) -> Unit,
letterPosition: suspend (Char) -> Int, letterPosition: suspend (Char) -> Int,
gridFocusRequester: FocusRequester, gridFocusRequester: FocusRequester,
showJumpButtons: Boolean, showJumpButtons: Boolean,
@ -92,13 +98,13 @@ fun CardGrid(
initialPosition: Int = 0, initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null, positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable ( cardContent: @Composable (
item: BaseItem?, item: T?,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
mod: Modifier, mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod -> ) -> Unit = { item, onClick, onLongClick, mod ->
GridCard( GridCard(
item = item, item = item as BaseItem?,
onClick = onClick, onClick = onClick,
onLongClick = onLongClick, onLongClick = onLongClick,
imageContentScale = ContentScale.FillBounds, imageContentScale = ContentScale.FillBounds,
@ -220,7 +226,7 @@ fun CardGrid(
return@onKeyEvent true return@onKeyEvent true
} else if (isPlayKeyUp(it)) { } else if (isPlayKeyUp(it)) {
val item = pager.getOrNull(focusedIndex) val item = pager.getOrNull(focusedIndex)
if (item?.type?.playable == true) { if (item?.playable == true) {
Timber.v("Clicked play on ${item.id}") Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(focusedIndex, item) onClickPlay.invoke(focusedIndex, item)
} }
@ -351,7 +357,6 @@ fun CardGrid(
remember(focusedIndex) { remember(focusedIndex) {
pager pager
.getOrNull(focusedIndex) .getOrNull(focusedIndex)
?.data
?.sortName ?.sortName
?.first() ?.first()
?.uppercaseChar() ?.uppercaseChar()

View file

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