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( data class BaseItem(
val data: BaseItemDto, val data: BaseItemDto,
val useSeriesForPrimary: Boolean, val useSeriesForPrimary: Boolean,
val imageUrlOverride: String? = null,
) : CardGridItem { ) : CardGridItem {
val id get() = data.id val id get() = data.id

View file

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

View file

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

View file

@ -5,12 +5,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState 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.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@ -102,51 +102,21 @@ class GenreViewModel
.execute(api, request) .execute(api, request)
.content.items .content.items
.map { .map {
Genre(it.id, it.name ?: "", null, Color.Black) Genre(it.id, it.name ?: "", null)
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
this@GenreViewModel.genres.value = genres this@GenreViewModel.genres.value = genres
loading.value = LoadingState.Success loading.value = LoadingState.Success
} }
val genreToUrl = ConcurrentHashMap<UUID, String?>() val genreToUrl =
val semaphore = Semaphore(4) getGenreImageMap(
genres api = api,
.map { genre -> imageUrlService = imageUrlService,
viewModelScope.async(Dispatchers.IO) { genres = genres.map { it.id },
semaphore.withPermit {
val item =
GetItemsRequestHandler
.execute(
api,
GetItemsRequest(
parentId = itemId, parentId = itemId,
recursive = true,
limit = 1,
sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1,
includeItemTypes = includeItemTypes, includeItemTypes = includeItemTypes,
genreIds = listOf(genre.id), cardWidthPx = cardWidthPx,
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 genresWithImages = val genresWithImages =
genres.map { genres.map {
it.copy( 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( data class Genre(
val id: UUID, val id: UUID,
val name: String, val name: String,
val imageUrl: String?, val imageUrl: String?,
val color: Color,
) : CardGridItem { ) : CardGridItem {
override val gridId: String get() = id.toString() override val gridId: String get() = id.toString()
override val playable: Boolean = false 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.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
import com.github.damontecres.wholphin.ui.cards.BannerCard 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.cards.ItemRow
import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.CircularProgress
import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogParams
@ -326,26 +327,11 @@ fun HomePageContent(
.animateItem(), .animateItem(),
horizontalPadding = viewOptions.spacing.dp, horizontalPadding = viewOptions.spacing.dp,
cardContent = { index, item, cardModifier, onClick, onLongClick -> cardContent = { index, item, cardModifier, onClick, onLongClick ->
BannerCard( val mod =
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 cardModifier
.onFocusChanged { .onFocusChanged {
if (it.isFocused) { if (it.isFocused) {
position = RowColumn(rowIndex, index) position = RowColumn(rowIndex, index)
// item?.let(onUpdateBackdrop)
} }
if (it.isFocused && onFocusPosition != null) { if (it.isFocused && onFocusPosition != null) {
val nonEmptyRowBefore = val nonEmptyRowBefore =
@ -368,10 +354,40 @@ fun HomePageContent(
return@onKeyEvent true return@onKeyEvent true
} }
return@onKeyEvent false 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, interactionSource = null,
cardHeight = viewOptions.heightDp.dp, 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.HomeRowConfigDisplay
import com.github.damontecres.wholphin.data.model.HomeRowViewOptions import com.github.damontecres.wholphin.data.model.HomeRowViewOptions
import com.github.damontecres.wholphin.services.BackdropService 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.LatestNextUpService
import com.github.damontecres.wholphin.services.UserPreferencesService 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.SlimItemFields
import com.github.damontecres.wholphin.ui.components.getGenreImageMap
import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.main.HomePageContent import com.github.damontecres.wholphin.ui.main.HomePageContent
import com.github.damontecres.wholphin.ui.main.LatestData import com.github.damontecres.wholphin.ui.main.LatestData
@ -65,6 +69,7 @@ class HomePageSettingsViewModel
private val navDrawerItemRepository: NavDrawerItemRepository, private val navDrawerItemRepository: NavDrawerItemRepository,
private val backdropService: BackdropService, private val backdropService: BackdropService,
private val latestNextUpService: LatestNextUpService, private val latestNextUpService: LatestNextUpService,
private val imageUrlService: ImageUrlService,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(HomePageSettingsState.EMPTY) private val _state = MutableStateFlow(HomePageSettingsState.EMPTY)
val state: StateFlow<HomePageSettingsState> = _state val state: StateFlow<HomePageSettingsState> = _state
@ -197,7 +202,21 @@ class HomePageSettingsViewModel
GetGenresRequestHandler GetGenresRequestHandler
.execute(api, request) .execute(api, request)
.content.items .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 = val name =
_state.value.libraries _state.value.libraries
.firstOrNull { it.itemId == row.parentId } .firstOrNull { it.itemId == row.parentId }
@ -208,7 +227,7 @@ class HomePageSettingsViewModel
listOf( listOf(
HomeRowLoadingState.Success( HomeRowLoadingState.Success(
title, title,
items, genres,
viewOptions = row.viewOptions, viewOptions = row.viewOptions,
), ),
) )
@ -347,7 +366,10 @@ class HomePageSettingsViewModel
HomeRowConfig.Genres( HomeRowConfig.Genres(
UUID.randomUUID(), UUID.randomUUID(),
library.itemId, library.itemId,
HomeRowViewOptions(), HomeRowViewOptions(
heightDp = (Cards.HEIGHT_2X3_DP * .75f).toInt(),
aspectRatio = AspectRatio.WIDE,
),
), ),
) )
} }