mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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:
parent
e60748a0d0
commit
693398536e
6 changed files with 252 additions and 24 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class ImageUrlService
|
|||
imageType: ImageType,
|
||||
fillWidth: Int? = null,
|
||||
fillHeight: Int? = null,
|
||||
): String? =
|
||||
): String =
|
||||
when (imageType) {
|
||||
ImageType.BACKDROP,
|
||||
ImageType.LOGO,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue