Handle genre cards

This commit is contained in:
Damontecres 2026-01-23 19:01:21 -05:00
parent 45e023d1b2
commit 00f6de8ec4
No known key found for this signature in database
6 changed files with 181 additions and 104 deletions

View file

@ -32,6 +32,7 @@ import kotlin.time.Duration
data class BaseItem(
val data: BaseItemDto,
val useSeriesForPrimary: Boolean,
val imageUrlOverride: String? = null,
) : CardGridItem {
val id get() = data.id

View file

@ -78,12 +78,13 @@ fun BannerCard(
val imageUrl =
remember(item, fillHeight, imageType) {
if (item != null) {
imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = null,
fillHeight = fillHeight,
)
item.imageUrlOverride
?: imageUrlService.getItemImageUrl(
item,
imageType,
fillWidth = null,
fillHeight = fillHeight,
)
} else {
null
}

View file

@ -42,11 +42,29 @@ fun GenreCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) = GenreCard(
genreId = genre?.id,
name = genre?.name,
imageUrl = genre?.imageUrl,
onClick = onClick,
onLongClick = onLongClick,
modifier = modifier,
interactionSource = interactionSource,
)
@Composable
fun GenreCard(
genreId: UUID?,
name: String?,
imageUrl: String?,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
) {
val background = rememberIdColor(genre?.id).copy(alpha = .6f)
val background = rememberIdColor(genreId).copy(alpha = .6f)
Card(
modifier =
modifier,
modifier = modifier,
onClick = onClick,
onLongClick = onLongClick,
interactionSource = interactionSource,
@ -63,12 +81,12 @@ fun GenreCard(
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
) {
if (genre?.imageUrl.isNotNullOrBlank()) {
if (imageUrl != null) {
AsyncImage(
model =
ImageRequest
.Builder(LocalContext.current)
.data(genre.imageUrl)
.data(imageUrl)
.crossfade(true)
.build(),
contentScale = ContentScale.FillBounds,
@ -88,7 +106,7 @@ fun GenreCard(
.background(background),
) {
Text(
text = genre?.name ?: "",
text = name ?: "",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
@ -112,7 +130,6 @@ private fun GenreCardPreview() {
UUID.randomUUID(),
"Adventure",
null,
Color.Black,
)
GenreCard(
genre = genre,

View file

@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
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.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
@ -102,51 +102,21 @@ class GenreViewModel
.execute(api, request)
.content.items
.map {
Genre(it.id, it.name ?: "", null, Color.Black)
Genre(it.id, it.name ?: "", null)
}
withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success
}
val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4)
genres
.map { genre ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
parentId = itemId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1,
includeItemTypes = includeItemTypes,
genreIds = listOf(genre.id),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
genreToUrl[genre.id] =
imageUrlService.getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = null,
useSeriesForPrimary = true,
imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx,
)
}
}
}
}.awaitAll()
val genreToUrl =
getGenreImageMap(
api = api,
imageUrlService = imageUrlService,
genres = genres.map { it.id },
parentId = itemId,
includeItemTypes = includeItemTypes,
cardWidthPx = cardWidthPx,
)
val genresWithImages =
genres.map {
it.copy(
@ -171,11 +141,61 @@ class GenreViewModel
}
}
suspend fun ViewModel.getGenreImageMap(
api: ApiClient,
imageUrlService: ImageUrlService,
genres: List<UUID>,
parentId: UUID,
includeItemTypes: List<BaseItemKind>?,
cardWidthPx: Int?,
): Map<UUID, String?> {
val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4)
genres
.map { genreId ->
viewModelScope.async(Dispatchers.IO) {
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
parentId = parentId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1,
includeItemTypes = includeItemTypes,
genreIds = listOf(genreId),
enableTotalRecordCount = false,
),
).content.items
.firstOrNull()
if (item != null) {
genreToUrl[genreId] =
imageUrlService.getItemImageUrl(
itemId = item.id,
itemType = item.type,
seriesId = null,
useSeriesForPrimary = true,
imageType = ImageType.BACKDROP,
imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx,
)
}
}
}
}.awaitAll()
return genreToUrl
}
@Stable
data class Genre(
val id: UUID,
val name: String,
val imageUrl: String?,
val color: Color,
) : CardGridItem {
override val gridId: String get() = id.toString()
override val playable: Boolean = false

View file

@ -48,6 +48,7 @@ import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard
import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogParams
@ -326,52 +327,67 @@ fun HomePageContent(
.animateItem(),
horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick ->
BannerCard(
name = item?.data?.seriesName ?: item?.name,
item = item,
aspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType.imageType,
imageContentScale = viewOptions.contentScale.scale,
cornerText = item?.ui?.episdodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier =
cardModifier
.onFocusChanged {
if (it.isFocused) {
position = RowColumn(rowIndex, index)
// item?.let(onUpdateBackdrop)
}
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
)
}
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
return@onKeyEvent false
},
interactionSource = null,
cardHeight = viewOptions.heightDp.dp,
)
val mod =
cardModifier
.onFocusChanged {
if (it.isFocused) {
position = RowColumn(rowIndex, index)
}
if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore =
homeRows
.subList(0, rowIndex)
.count {
it is HomeRowLoadingState.Success && it.items.isEmpty()
}
onFocusPosition.invoke(
RowColumn(
rowIndex - nonEmptyRowBefore,
index,
),
)
}
}.onKeyEvent {
if (isPlayKeyUp(it) && item?.type?.playable == true) {
Timber.v("Clicked play on ${item.id}")
onClickPlay.invoke(position, item)
return@onKeyEvent true
}
return@onKeyEvent false
}
when (item?.type) {
BaseItemKind.GENRE -> {
GenreCard(
genreId = item.id,
name = item.name,
imageUrl = item.imageUrlOverride,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod.height(viewOptions.heightDp.dp),
)
}
else -> {
BannerCard(
name = item?.data?.seriesName ?: item?.name,
item = item,
aspectRatio = viewOptions.aspectRatio.ratio,
imageType = viewOptions.imageType.imageType,
imageContentScale = viewOptions.contentScale.scale,
cornerText = item?.ui?.episdodeUnplayedCornerText,
played = item?.data?.userData?.played ?: false,
favorite = item?.favorite ?: false,
playPercent =
item?.data?.userData?.playedPercentage
?: 0.0,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
interactionSource = null,
cardHeight = viewOptions.heightDp.dp,
)
}
}
},
)
}

View file

@ -32,9 +32,13 @@ import com.github.damontecres.wholphin.data.model.HomeRowConfig
import com.github.damontecres.wholphin.data.model.HomeRowConfigDisplay
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.ImageUrlService
import com.github.damontecres.wholphin.services.LatestNextUpService
import com.github.damontecres.wholphin.services.UserPreferencesService
import com.github.damontecres.wholphin.ui.AspectRatio
import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.LatestData
@ -65,6 +69,7 @@ class HomePageSettingsViewModel
private val navDrawerItemRepository: NavDrawerItemRepository,
private val backdropService: BackdropService,
private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow<HomePageSettingsState> = _state
@ -197,7 +202,21 @@ class HomePageSettingsViewModel
GetGenresRequestHandler
.execute(api, request)
.content.items
.map { BaseItem(it, false) }
val genreIds = items.map { it.id }
val genreImages =
getGenreImageMap(
api = api,
imageUrlService = imageUrlService,
genres = genreIds,
parentId = row.parentId,
includeItemTypes = null,
cardWidthPx = null,
)
val genres =
items.map {
BaseItem(it, false, genreImages[it.id])
}
val name =
_state.value.libraries
.firstOrNull { it.itemId == row.parentId }
@ -208,7 +227,7 @@ class HomePageSettingsViewModel
listOf(
HomeRowLoadingState.Success(
title,
items,
genres,
viewOptions = row.viewOptions,
),
)
@ -347,7 +366,10 @@ class HomePageSettingsViewModel
HomeRowConfig.Genres(
UUID.randomUUID(),
library.itemId,
HomeRowViewOptions(),
HomeRowViewOptions(
heightDp = (Cards.HEIGHT_2X3_DP * .75f).toInt(),
aspectRatio = AspectRatio.WIDE,
),
),
)
}