Fixes to genre grids (#693)

## Description
Use only TV series genres for a TV library's genre tab. This ensures
only series are shown, not episodes, which removes some of the seemingly
empty genres.

Also fixes the image filtering to ensure an item with a backdrop is
always used if available.

### Related issues
Fixes #689
Fixes #690
This commit is contained in:
Ray 2026-01-13 16:05:09 -05:00 committed by GitHub
parent 18e4877736
commit c4e64c3367
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 40 additions and 26 deletions

View file

@ -30,6 +30,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
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.focus.focusRequester
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -783,12 +784,15 @@ fun CollectionFolderGridContent(
var showHeader by rememberSaveable { mutableStateOf(true) } var showHeader by rememberSaveable { mutableStateOf(true) }
var showViewOptions by rememberSaveable { mutableStateOf(false) } var showViewOptions by rememberSaveable { mutableStateOf(false) }
var viewOptions by remember { mutableStateOf(viewOptions) } var viewOptions by remember { mutableStateOf(viewOptions) }
val headerRowFocusRequester = remember { FocusRequester() }
val gridFocusRequester = remember { FocusRequester() } val gridFocusRequester = remember { FocusRequester() }
if (pager.isNotEmpty()) { if (pager.isNotEmpty()) {
RequestOrRestoreFocus(gridFocusRequester) RequestOrRestoreFocus(gridFocusRequester)
} else { } else {
focusRequesterOnEmpty?.tryRequestFocus() LaunchedEffect(Unit) {
(focusRequesterOnEmpty ?: headerRowFocusRequester).tryRequestFocus()
}
} }
var backdropImageUrl by remember { mutableStateOf<String?>(null) } var backdropImageUrl by remember { mutableStateOf<String?>(null) }
@ -833,7 +837,8 @@ fun CollectionFolderGridContent(
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp, end = endPadding) .padding(start = 16.dp, end = endPadding)
.fillMaxWidth(), .fillMaxWidth()
.focusRequester(headerRowFocusRequester),
) { ) {
if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) { if (sortOptions.isNotEmpty() || filterOptions.isNotEmpty()) {
Row( Row(

View file

@ -37,6 +37,9 @@ import com.github.damontecres.wholphin.util.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler 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.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
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.async
@ -55,29 +58,32 @@ import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@HiltViewModel @HiltViewModel(assistedFactory = GenreViewModel.Factory::class)
class GenreViewModel class GenreViewModel
@Inject @AssistedInject
constructor( constructor(
private val api: ApiClient, private val api: ApiClient,
private val imageUrlService: ImageUrlService, private val imageUrlService: ImageUrlService,
private val serverRepository: ServerRepository, private val serverRepository: ServerRepository,
val navigationManager: NavigationManager, val navigationManager: NavigationManager,
@Assisted private val itemId: UUID,
@Assisted private val includeItemTypes: List<BaseItemKind>?,
) : ViewModel() { ) : ViewModel() {
private lateinit var itemId: UUID @AssistedFactory
interface Factory {
fun create(
itemId: UUID,
includeItemTypes: List<BaseItemKind>?,
): GenreViewModel
}
val item = MutableLiveData<BaseItem?>(null) val item = MutableLiveData<BaseItem?>(null)
val loading = MutableLiveData<LoadingState>(LoadingState.Pending) val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<Genre>>(listOf()) val genres = MutableLiveData<List<Genre>>(listOf())
fun init( fun init(cardWidthPx: Int) {
itemId: UUID,
cardWidthPx: Int,
) {
loading.value = LoadingState.Loading loading.value = LoadingState.Loading
this.itemId = itemId
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
val item = val item =
api.userLibraryApi.getItem(itemId = itemId).content.let { api.userLibraryApi.getItem(itemId = itemId).content.let {
@ -89,6 +95,7 @@ class GenreViewModel
userId = serverRepository.currentUser.value?.id, userId = serverRepository.currentUser.value?.id,
parentId = itemId, parentId = itemId,
fields = SlimItemFields, fields = SlimItemFields,
includeItemTypes = includeItemTypes,
) )
val genres = val genres =
GetGenresRequestHandler GetGenresRequestHandler
@ -97,12 +104,10 @@ class GenreViewModel
.map { .map {
Genre(it.id, it.name ?: "", null, Color.Black) Genre(it.id, it.name ?: "", null, Color.Black)
} }
// val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
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 excludeItemIds = mutableSetOf<UUID>()
val genreToUrl = ConcurrentHashMap<UUID, String?>() val genreToUrl = ConcurrentHashMap<UUID, String?>()
val semaphore = Semaphore(4) val semaphore = Semaphore(4)
genres genres
@ -114,34 +119,28 @@ class GenreViewModel
.execute( .execute(
api, api,
GetItemsRequest( GetItemsRequest(
// excludeItemIds = excludeItemIds,
parentId = itemId, parentId = itemId,
recursive = true, recursive = true,
limit = 1, limit = 1,
sortBy = listOf(ItemSortBy.RANDOM), sortBy = listOf(ItemSortBy.RANDOM),
fields = listOf(ItemFields.GENRES), fields = listOf(ItemFields.GENRES),
imageTypes = listOf(ImageType.THUMB), imageTypes = listOf(ImageType.BACKDROP),
imageTypeLimit = 1, imageTypeLimit = 1,
includeItemTypes = includeItemTypes = includeItemTypes,
listOf(
BaseItemKind.MOVIE,
BaseItemKind.SERIES,
),
genreIds = listOf(genre.id), genreIds = listOf(genre.id),
enableTotalRecordCount = false, enableTotalRecordCount = false,
), ),
).content.items ).content.items
.firstOrNull() .firstOrNull()
if (item != null) { if (item != null) {
// excludeItemIds.add(item.id)
genreToUrl[genre.id] = genreToUrl[genre.id] =
imageUrlService.getItemImageUrl( imageUrlService.getItemImageUrl(
itemId = item.id, itemId = item.id,
itemType = item.type, itemType = item.type,
seriesId = null, seriesId = null,
useSeriesForPrimary = false, useSeriesForPrimary = true,
imageType = ImageType.BACKDROP, imageType = ImageType.BACKDROP,
imageTags = emptyMap(), imageTags = item.imageTags.orEmpty(),
fillWidth = cardWidthPx, fillWidth = cardWidthPx,
) )
} }
@ -186,8 +185,12 @@ data class Genre(
@Composable @Composable
fun GenreCardGrid( fun GenreCardGrid(
itemId: UUID, itemId: UUID,
includeItemTypes: List<BaseItemKind>?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: GenreViewModel = hiltViewModel(), viewModel: GenreViewModel =
hiltViewModel<GenreViewModel, GenreViewModel.Factory>(
creationCallback = { it.create(itemId, includeItemTypes) },
),
) { ) {
val columns = 4 val columns = 4
val spacing = 16.dp val spacing = 16.dp
@ -205,7 +208,7 @@ fun GenreCardGrid(
} }
} }
OneTimeLaunchedEffect { OneTimeLaunchedEffect {
viewModel.init(itemId, cardWidthPx) viewModel.init(cardWidthPx)
} }
val loading by viewModel.loading.observeAsState(LoadingState.Pending) val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val genres by viewModel.genres.observeAsState(listOf()) val genres by viewModel.genres.observeAsState(listOf())
@ -239,7 +242,11 @@ fun GenreCardGrid(
genre.name, genre.name,
item?.title, item?.title,
).joinToString(" "), ).joinToString(" "),
filter = GetItemsFilter(genres = listOf(genre.id)), filter =
GetItemsFilter(
genres = listOf(genre.id),
includeItemTypes = includeItemTypes,
),
useSavedLibraryDisplayInfo = false, useSavedLibraryDisplayInfo = false,
), ),
recursive = true, recursive = true,

View file

@ -179,6 +179,7 @@ fun CollectionFolderMovie(
3 -> { 3 -> {
GenreCardGrid( GenreCardGrid(
itemId = destination.itemId, itemId = destination.itemId,
includeItemTypes = listOf(BaseItemKind.MOVIE),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)

View file

@ -148,6 +148,7 @@ fun CollectionFolderTv(
2 -> { 2 -> {
GenreCardGrid( GenreCardGrid(
itemId = destination.itemId, itemId = destination.itemId,
includeItemTypes = listOf(BaseItemKind.SERIES),
modifier = modifier =
Modifier Modifier
.padding(start = 16.dp) .padding(start = 16.dp)