Show genres tab for TV & Movies (#16)

Adds a tab to the TV Show & Movie screens which displays the genres in
that library as a grid.

You can select the genre to see the items in that library for the genre.

This PR also adds backend changes for further filtering to implement
#13.
This commit is contained in:
damontecres 2025-10-16 16:49:40 -04:00 committed by GitHub
parent 6a896cf600
commit 0e195c18d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 330 additions and 57 deletions

View file

@ -0,0 +1,31 @@
@file:UseSerializers(UuidSerializer::class)
package com.github.damontecres.wholphin.data.model
import com.github.damontecres.wholphin.util.UuidSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import java.util.UUID
@Serializable
data class GetItemsFilter(
val favorite: Boolean? = null,
val genres: List<UUID>? = null,
val minCriticRating: Double? = null,
val persons: List<UUID>? = null,
val played: Boolean? = null,
val studios: List<UUID>? = null,
val tags: List<String>? = null,
) {
fun applyTo(req: GetItemsRequest) =
req.copy(
isFavorite = favorite,
genreIds = genres,
minCriticRating = minCriticRating,
personIds = persons,
isPlayed = played,
studioIds = studios,
tags = tags,
)
}

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
@ -313,6 +314,12 @@ fun rememberPosition(initialPosition: RowColumn = RowColumn(-1, -1)) =
)
}
/**
* Convenient way to [rememberSaveable] a Int
*/
@Composable
fun rememberInt(initial: Int = 0) = rememberSaveable { mutableIntStateOf(initial) }
/**
* Show a [Toast]. Ensures it runs on the main thread.
*/

View file

@ -38,6 +38,7 @@ fun GridCard(
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
imageAspectRatio: Float = 2f / 3f,
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
@ -86,7 +87,7 @@ fun GridCard(
modifier =
Modifier
.fillMaxWidth()
.aspectRatio(2f / 3f) // TODO
.aspectRatio(imageAspectRatio)
.background(MaterialTheme.colorScheme.surfaceVariant),
)
}

View file

@ -42,6 +42,7 @@ fun SeasonCard(
imageWidth: Dp = Dp.Unspecified,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
showImageOverlay: Boolean = false,
aspectRatio: Float = item?.data?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f),
) {
val dto = item?.data
val focused by interactionSource.collectIsFocusedAsState()
@ -62,7 +63,7 @@ fun SeasonCard(
} else {
focusedAfterDelay = false
}
val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
// val aspectRatio = dto?.primaryImageAspectRatio?.toFloat() ?: (2f / 3f)
val width = imageHeight * aspectRatio
val height = imageWidth * (1f / aspectRatio)
Column(

View file

@ -27,6 +27,7 @@ import androidx.lifecycle.viewModelScope
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
@ -36,7 +37,6 @@ import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.data.VideoSortOptions
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.detail.ItemViewModel
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.tryRequestFocus
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetItemsRequestHandler
@ -66,12 +66,14 @@ class CollectionFolderViewModel
val loading = MutableLiveData<LoadingState>(LoadingState.Loading)
val pager = MutableLiveData<List<BaseItem?>>(listOf())
val sortAndDirection = MutableLiveData<SortAndDirection>()
val filter = MutableLiveData<GetItemsFilter>(GetItemsFilter())
fun init(
itemId: UUID,
potential: BaseItem?,
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
): Job =
viewModelScope.launch(
LoadingExceptionHandler(
@ -80,12 +82,13 @@ class CollectionFolderViewModel
) + Dispatchers.IO,
) {
fetchItem(itemId, potential)
loadResults(sortAndDirection, recursive)
loadResults(sortAndDirection, recursive, filter)
}
fun loadResults(
sortAndDirection: SortAndDirection,
recursive: Boolean,
filter: GetItemsFilter,
) {
item.value?.let { item ->
viewModelScope.launch(Dispatchers.IO) {
@ -93,6 +96,7 @@ class CollectionFolderViewModel
pager.value = listOf()
loading.value = LoadingState.Loading
this@CollectionFolderViewModel.sortAndDirection.value = sortAndDirection
this@CollectionFolderViewModel.filter.value = filter
}
val includeItemTypes =
when (item.data.collectionType) {
@ -112,25 +116,27 @@ class CollectionFolderViewModel
else -> listOf()
}
val request =
GetItemsRequest(
parentId = item.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = listOf(item.id),
sortBy =
listOf(
sortAndDirection.sort,
ItemSortBy.SORT_NAME,
ItemSortBy.PRODUCTION_YEAR,
),
sortOrder =
listOf(
sortAndDirection.direction,
SortOrder.ASCENDING,
SortOrder.ASCENDING,
),
fields = DefaultItemFields,
filter.applyTo(
GetItemsRequest(
parentId = item.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = recursive,
excludeItemIds = listOf(item.id),
sortBy =
listOf(
sortAndDirection.sort,
ItemSortBy.SORT_NAME,
ItemSortBy.PRODUCTION_YEAR,
),
sortOrder =
listOf(
sortAndDirection.direction,
SortOrder.ASCENDING,
SortOrder.ASCENDING,
),
fields = DefaultItemFields,
),
)
val newPager =
ApiRequestPager(
@ -182,7 +188,9 @@ class CollectionFolderViewModel
@Composable
fun CollectionFolderGrid(
preferences: UserPreferences,
destination: Destination.MediaItem,
itemId: UUID,
item: BaseItem?,
initialFilter: GetItemsFilter,
recursive: Boolean,
onClickItem: (BaseItem) -> Unit,
modifier: Modifier = Modifier,
@ -196,9 +204,10 @@ fun CollectionFolderGrid(
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
) {
OneTimeLaunchedEffect {
viewModel.init(destination.itemId, destination.item, initialSortAndDirection, recursive)
viewModel.init(itemId, item, initialSortAndDirection, recursive, initialFilter)
}
val sortAndDirection by viewModel.sortAndDirection.observeAsState(initialSortAndDirection)
val filter by viewModel.filter.observeAsState(initialFilter)
val loading by viewModel.loading.observeAsState(LoadingState.Loading)
val item by viewModel.item.observeAsState()
val pager by viewModel.pager.observeAsState()
@ -218,7 +227,7 @@ fun CollectionFolderGrid(
modifier = modifier,
onClickItem = onClickItem,
onSortChange = {
viewModel.loadResults(it, recursive)
viewModel.loadResults(it, recursive, filter)
},
showTitle = showTitle,
positionCallback = positionCallback,

View file

@ -0,0 +1,135 @@
package com.github.damontecres.wholphin.ui.components
import androidx.compose.foundation.focusable
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.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.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.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.OneTimeLaunchedEffect
import com.github.damontecres.wholphin.ui.cards.GridCard
import com.github.damontecres.wholphin.ui.detail.CardGrid
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.nav.NavigationManager
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.LoadingExceptionHandler
import com.github.damontecres.wholphin.util.LoadingState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import java.util.UUID
import javax.inject.Inject
@HiltViewModel
class GenreViewModel
@Inject
constructor(
private val api: ApiClient,
val navigationManager: NavigationManager,
) : ViewModel() {
private lateinit var itemId: UUID
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val genres = MutableLiveData<List<BaseItem?>>(listOf())
fun init(itemId: UUID) {
loading.value = LoadingState.Loading
this.itemId = itemId
viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) {
val request =
GetGenresRequest(
parentId = itemId,
fields = DefaultItemFields,
)
val pager = ApiRequestPager(api, request, GetGenresRequestHandler, viewModelScope).init()
withContext(Dispatchers.Main) {
genres.value = pager
loading.value = LoadingState.Success
}
}
}
suspend fun positionOfLetter(letter: Char): Int {
val request =
GetGenresRequest(
parentId = itemId,
nameLessThan = letter.toString(),
limit = 0,
enableTotalRecordCount = true,
)
val result by GetGenresRequestHandler.execute(api, request)
return result.totalRecordCount
}
}
@Composable
fun GenreCardGrid(
itemId: UUID,
modifier: Modifier = Modifier,
viewModel: GenreViewModel = hiltViewModel(),
) {
OneTimeLaunchedEffect {
viewModel.init(itemId)
}
val loading by viewModel.loading.observeAsState(LoadingState.Pending)
val genres by viewModel.genres.observeAsState(listOf())
val gridFocusRequester = remember { FocusRequester() }
when (val st = loading) {
LoadingState.Pending,
LoadingState.Loading,
-> LoadingPage(modifier.focusable())
is LoadingState.Error -> ErrorMessage(st, modifier.focusable())
LoadingState.Success ->
Box(modifier = modifier) {
LaunchedEffect(Unit) { gridFocusRequester.tryRequestFocus() }
CardGrid(
pager = genres,
onClickItem = { genre ->
viewModel.navigationManager.navigateTo(
Destination.FilteredCollection(
itemId = itemId,
filter = GetItemsFilter(genres = listOf(genre.id)),
recursive = true,
),
)
},
onLongClickItem = {},
letterPosition = { viewModel.positionOfLetter(it) },
gridFocusRequester = gridFocusRequester,
showJumpButtons = false,
showLetterButtons = true,
modifier = Modifier.fillMaxSize(),
initialPosition = 0,
positionCallback = { columns, position ->
},
cardContent = { item: BaseItem?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
imageAspectRatio = 1f,
)
},
)
}
}
}

View file

@ -77,6 +77,19 @@ fun CardGrid(
modifier: Modifier = Modifier,
initialPosition: Int = 0,
positionCallback: ((columns: Int, position: Int) -> Unit)? = null,
cardContent: @Composable (
item: BaseItem?,
onClick: () -> Unit,
onLongClick: () -> Unit,
mod: Modifier,
) -> Unit = { item, onClick, onLongClick, mod ->
GridCard(
item = item,
onClick = onClick,
onLongClick = onLongClick,
modifier = mod,
)
},
) {
val startPosition = initialPosition.coerceIn(0, (pager.size - 1).coerceAtLeast(0))
val columns = 6
@ -249,34 +262,33 @@ fun CardGrid(
Modifier
}
val item = pager[index]
GridCard(
item = item,
onClick = {
cardContent(
item,
{
if (item != null) {
focusedIndex = index
onClickItem.invoke(item)
}
},
onLongClick = { if (item != null) onLongClickItem.invoke(item) },
modifier =
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
{ if (item != null) onLongClickItem.invoke(item) },
mod
.ifElse(index == 0, Modifier.focusRequester(zeroFocus))
.onFocusChanged { focusState ->
if (DEBUG) {
Timber.v(
"$index isFocused=${focusState.isFocused}",
)
}
if (focusState.isFocused) {
// Focused, so set that up
focusOn(index)
positionCallback?.invoke(columns, index)
} else if (focusedIndex == index) {
// savedFocusedIndex = index
// // Was focused on this, so mark unfocused
// focusedIndex = -1
}
},
}
},
)
}
}

View file

@ -9,24 +9,30 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.preferences.PreferencesViewModel
import java.util.UUID
@Composable
fun CollectionFolderGeneric(
preferences: UserPreferences,
destination: Destination.MediaItem,
itemId: UUID,
item: BaseItem?,
recursive: Boolean,
modifier: Modifier = Modifier,
filter: GetItemsFilter = GetItemsFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
) {
var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid(
preferences = preferences,
onClickItem = { preferencesViewModel.navigationManager.navigateTo(it.destination()) },
destination = destination,
itemId = itemId,
item = item,
initialFilter = filter,
showTitle = showHeader,
recursive = recursive,
modifier =

View file

@ -30,10 +30,12 @@ import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.rememberTab
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedMovie
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
@ -55,7 +57,7 @@ fun CollectionFolderMovie(
0
}
val tabs = listOf("Recommended", "Library")
val tabs = listOf("Recommended", "Library", "Genres")
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
@ -154,7 +156,9 @@ fun CollectionFolderMovie(
CollectionFolderGrid(
preferences = preferences,
onClickItem = onClickItem,
destination = destination,
itemId = destination.itemId,
item = destination.item,
initialFilter = GetItemsFilter(),
showTitle = false,
recursive = true,
modifier =
@ -167,6 +171,16 @@ fun CollectionFolderMovie(
},
)
}
2 -> {
GenreCardGrid(
itemId = destination.itemId,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
}
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}

View file

@ -30,10 +30,12 @@ import androidx.tv.material3.TabRow
import androidx.tv.material3.TabRowDefaults
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.preferences.rememberTab
import com.github.damontecres.wholphin.ui.components.CollectionFolderGrid
import com.github.damontecres.wholphin.ui.components.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow
import com.github.damontecres.wholphin.ui.ifElse
import com.github.damontecres.wholphin.ui.nav.Destination
@ -55,7 +57,7 @@ fun CollectionFolderTv(
0
}
val tabs = listOf("Recommended", "Library")
val tabs = listOf("Recommended", "Library", "Genres")
var focusTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() }
@ -153,7 +155,9 @@ fun CollectionFolderTv(
1 -> {
CollectionFolderGrid(
preferences = preferences,
destination = destination,
itemId = destination.itemId,
item = destination.item,
initialFilter = GetItemsFilter(),
showTitle = false,
recursive = true,
modifier =
@ -167,6 +171,16 @@ fun CollectionFolderTv(
onClickItem = onClickItem,
)
}
2 -> {
GenreCardGrid(
itemId = destination.itemId,
modifier =
Modifier
.padding(start = 16.dp)
.fillMaxSize()
.focusRequester(focusRequester),
)
}
else -> ErrorMessage("Invalid tab index $selectedTabIndex", null)
}
}

View file

@ -4,6 +4,7 @@ package com.github.damontecres.wholphin.ui.nav
import androidx.navigation3.runtime.NavKey
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.GetItemsFilter
import com.github.damontecres.wholphin.ui.detail.series.SeasonEpisode
import com.github.damontecres.wholphin.ui.preferences.PreferenceScreenOption
import com.github.damontecres.wholphin.util.UuidSerializer
@ -75,6 +76,13 @@ sealed class Destination(
constructor(item: BaseItem) : this(item.id, item.resumeMs ?: 0, item)
}
@Serializable
data class FilteredCollection(
val itemId: UUID,
val filter: GetItemsFilter,
val recursive: Boolean,
) : Destination(false)
@Serializable
data object UpdateApp : Destination(true)

View file

@ -93,7 +93,8 @@ fun DestinationContent(
BaseItemKind.BOX_SET ->
CollectionFolderGeneric(
preferences,
destination,
destination.itemId,
destination.item,
false,
modifier,
)
@ -117,7 +118,8 @@ fun DestinationContent(
CollectionType.BOXSETS ->
CollectionFolderGeneric(
preferences,
destination,
destination.itemId,
destination.item,
true,
modifier,
)
@ -125,7 +127,8 @@ fun DestinationContent(
else ->
CollectionFolderGeneric(
preferences,
destination,
destination.itemId,
destination.item,
false,
modifier,
)
@ -141,7 +144,8 @@ fun DestinationContent(
BaseItemKind.USER_VIEW ->
CollectionFolderGeneric(
preferences,
destination,
destination.itemId,
destination.item,
true,
modifier,
)
@ -151,6 +155,16 @@ fun DestinationContent(
}
}
is Destination.FilteredCollection ->
CollectionFolderGeneric(
preferences = preferences,
itemId = destination.itemId,
item = null,
filter = destination.filter,
recursive = destination.recursive,
modifier = modifier,
)
Destination.UpdateApp -> InstallUpdatePage(preferences, modifier)
Destination.License -> LicenseInfo(modifier)

View file

@ -15,12 +15,14 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.Response
import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.itemsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi
import org.jellyfin.sdk.api.client.extensions.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.model.api.BaseItemDtoQueryResult
import org.jellyfin.sdk.model.api.request.GetEpisodesRequest
import org.jellyfin.sdk.model.api.request.GetGenresRequest
import org.jellyfin.sdk.model.api.request.GetItemsRequest
import org.jellyfin.sdk.model.api.request.GetNextUpRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
@ -292,3 +294,22 @@ val GetPlaylistItemsRequestHandler =
request: GetPlaylistItemsRequest,
): Response<BaseItemDtoQueryResult> = api.playlistsApi.getPlaylistItems(request)
}
val GetGenresRequestHandler =
object : RequestHandler<GetGenresRequest> {
override fun prepare(
request: GetGenresRequest,
startIndex: Int,
limit: Int,
enableTotalRecordCount: Boolean,
): GetGenresRequest =
request.copy(
startIndex = startIndex,
limit = limit,
)
override suspend fun execute(
api: ApiClient,
request: GetGenresRequest,
): Response<BaseItemDtoQueryResult> = api.genresApi.getGenres(request)
}