From ade564d907179cf364d745b47f2d05a163bdddce Mon Sep 17 00:00:00 2001 From: Ray <154766448+damontecres@users.noreply.github.com> Date: Tue, 7 Apr 2026 16:03:58 -0400 Subject: [PATCH] Add studio tab & filtering to TV show libraries (#1211) ## Description Adds a tab for Studios (TV networks) to TV libraries. This displays a grid of all of the studios. Clicking one displays the series produced by the studio. Additionally, adds a filter to the library tab to filter by studio. You can select multiple studios, but its not the best UX since there will likely be many studios (I have almost 200). Finally, you can add a home row for studios in a library. ### Related issues Closes #1002 ### Testing Mostly manual testing via emulator ## Screenshots ### Studios tab for a library ![studios_grid Large](https://github.com/user-attachments/assets/d51e6d20-3768-4bd5-8a09-45cd491064b2) ### Filter drop down for studios ![filter_by_studios Large](https://github.com/user-attachments/assets/7dbd4c13-4194-4674-b272-d42257368ce2) ### Updates series details Shows the studio above the genres ![series_details_studio Large](https://github.com/user-attachments/assets/745caa9b-838c-4e8b-b0f8-69d5f316287a) ## AI or LLM usage None --- .../wholphin/data/filter/ItemFilterBy.kt | 39 ++++ .../wholphin/data/model/BaseItem.kt | 29 +++ .../wholphin/data/model/HomeRowConfig.kt | 12 + .../wholphin/services/HomeSettingsService.kt | 63 +++++ .../wholphin/ui/cards/StudioCard.kt | 150 ++++++++++++ .../wholphin/ui/components/FilterByButton.kt | 9 +- .../wholphin/ui/components/GenreCardGrid.kt | 1 + .../wholphin/ui/components/StudioCardGrid.kt | 220 ++++++++++++++++++ .../wholphin/ui/data/ItemDetailsDialogInfo.kt | 7 + .../wholphin/ui/detail/CollectionFolderTv.kt | 16 ++ .../wholphin/ui/detail/movie/MovieDetails.kt | 2 + .../ui/detail/series/SeriesDetails.kt | 23 +- .../damontecres/wholphin/ui/main/HomePage.kt | 12 + .../main/settings/HomeLibraryRowTypeList.kt | 11 + .../ui/main/settings/HomeSettingsViewModel.kt | 14 ++ .../wholphin/ui/nav/Destination.kt | 1 + .../wholphin/ui/nav/DestinationContent.kt | 8 +- .../wholphin/ui/util/FilterUtils.kt | 13 ++ .../wholphin/util/ApiRequestPager.kt | 22 ++ app/src/main/res/values/strings.xml | 1 + .../wholphin/test/TestHomeRowSamples.kt | 2 + 21 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt index 1d536f28..37fd9514 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/filter/ItemFilterBy.kt @@ -17,6 +17,19 @@ val DefaultFilterOptions = DecadeFilter, ) +val DefaultTvFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + GenreFilter, + StudioFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + val DefaultForFavoritesFilterOptions = listOf( PlayedFilter, @@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions = listOf( PlayedFilter, FavoriteFilter, + StudioFilter, + CommunityRatingFilter, + OfficialRatingFilter, + VideoTypeFilter, + YearFilter, + DecadeFilter, + ) + +val DefaultForStudiosFilterOptions = + listOf( + PlayedFilter, + FavoriteFilter, + GenreFilter, CommunityRatingFilter, OfficialRatingFilter, VideoTypeFilter, @@ -183,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy { filter: GetItemsFilter, ): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble()) } + +data object StudioFilter : ItemFilterBy> { + override val stringRes: Int = R.string.studios + + override val supportMultiple: Boolean = true + + override fun get(filter: GetItemsFilter): List? = filter.studios + + override fun set( + value: List?, + filter: GetItemsFilter, + ): GetItemsFilter = filter.copy(studios = value) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt index cf7461ea..1b9523cc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/BaseItem.kt @@ -270,6 +270,7 @@ fun createGenreDestination( includeItemTypes: List?, ) = Destination.FilteredCollection( itemId = parentId, + parentType = BaseItemKind.GENRE, filter = CollectionFolderFilter( nameOverride = @@ -286,3 +287,31 @@ fun createGenreDestination( ), recursive = true, ) + +fun createStudioDestination( + studioId: UUID, + name: String, + parentId: UUID, + parentName: String?, + includeItemTypes: List?, +) = Destination.FilteredCollection( + itemId = parentId, + parentType = BaseItemKind.STUDIO, + filter = + CollectionFolderFilter( + nameOverride = + listOfNotNull( + name, + parentName, + ).joinToString(" "), + filter = + GetItemsFilter( + studios = listOf(studioId), + includeItemTypes = includeItemTypes, + ), + useSavedLibraryDisplayInfo = false, + ), + recursive = true, +) + +val BaseItem.studioNames get() = data.studios?.mapNotNull { it.name }.orEmpty() diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt index 1398bc0b..74a6c6d1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/HomeRowConfig.kt @@ -90,6 +90,18 @@ sealed interface HomeRowConfig { override fun updateViewOptions(viewOptions: HomeRowViewOptions): Genres = this.copy(viewOptions = viewOptions) } + /** + * Row of a studios in a library + */ + @Serializable + @SerialName("Studios") + data class Studios( + val parentId: UUID, + override val viewOptions: HomeRowViewOptions = HomeRowViewOptions.genreDefault, + ) : HomeRowConfig { + override fun updateViewOptions(viewOptions: HomeRowViewOptions): Studios = this.copy(viewOptions = viewOptions) + } + /** * Favorites for a specific type */ diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt index 2abe6a1e..cbad6d32 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/HomeSettingsService.kt @@ -8,6 +8,7 @@ import com.github.damontecres.wholphin.data.model.HomePageSettings import com.github.damontecres.wholphin.data.model.HomeRowConfig import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION import com.github.damontecres.wholphin.data.model.createGenreDestination +import com.github.damontecres.wholphin.data.model.createStudioDestination import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.ui.DefaultItemFields @@ -21,6 +22,7 @@ import com.github.damontecres.wholphin.ui.toServerString import com.github.damontecres.wholphin.util.GetGenresRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetPersonsHandler +import com.github.damontecres.wholphin.util.GetStudiosRequestHandler import com.github.damontecres.wholphin.util.HomeRowLoadingState import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success @@ -58,6 +60,7 @@ import org.jellyfin.sdk.model.api.request.GetLatestMediaRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest import org.jellyfin.sdk.model.api.request.GetRecordingsRequest +import org.jellyfin.sdk.model.api.request.GetStudiosRequest import timber.log.Timber import java.io.File import javax.inject.Inject @@ -483,6 +486,15 @@ class HomeSettingsService ) } + is HomeRowConfig.Studios -> { + val name = getItemName(config.parentId) ?: "" + HomeRowConfigDisplay( + id, + context.getString(R.string.studios_in, name), + config, + ) + } + is HomeRowConfig.GetItems -> { HomeRowConfigDisplay(id, config.name, config) } @@ -701,6 +713,57 @@ class HomeSettingsService ) } + is HomeRowConfig.Studios -> { + val request = + GetStudiosRequest( + parentId = row.parentId, + userId = userDto.id, + limit = limit, + includeItemTypes = listOf(BaseItemKind.SERIES), + ) + val items = + GetStudiosRequestHandler + .execute(api, request) + .content.items + val library = + libraries + .firstOrNull { it.itemId == row.parentId } + val title = + library?.name?.let { context.getString(R.string.studios_in, it) } + ?: context.getString(R.string.studios) + val studios = + items.map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + ) + BaseItem( + it, + false, + imageUrl, + createStudioDestination( + studioId = it.id, + name = it.name ?: "", + parentId = row.parentId, + parentName = library?.name, + includeItemTypes = + library?.collectionType?.let { + getTypeFor(it)?.let { + listOf(it) + } + }, + ), + ) + } + + Success( + title, + studios, + viewOptions = row.viewOptions, + ) + } + is HomeRowConfig.RecentlyAdded -> { val library = libraries diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt new file mode 100644 index 00000000..fb89a58b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/StudioCard.kt @@ -0,0 +1,150 @@ +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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.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.components.Studio +import com.github.damontecres.wholphin.ui.isNotNullOrBlank +import com.github.damontecres.wholphin.ui.setup.rememberIdColor +import com.github.damontecres.wholphin.ui.theme.WholphinTheme +import java.util.UUID + +@Composable +fun StudioCard( + studio: Studio?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = StudioCard( + studioId = studio?.id, + name = studio?.name, + imageUrl = studio?.imageUrl, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier, + interactionSource = interactionSource, +) + +@Composable +fun StudioCard( + studioId: UUID?, + name: String?, + imageUrl: String?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val background = rememberIdColor(studioId).copy(alpha = .4f) + var error by remember { mutableStateOf(false) } + 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)), + ) { + if (imageUrl != null && !error) { + AsyncImage( + model = + ImageRequest + .Builder(LocalContext.current) + .data(imageUrl) + .crossfade(true) + .build(), + contentScale = ContentScale.FillBounds, + contentDescription = null, + onError = { + error = true + }, + modifier = + Modifier + .alpha(.75f) + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize(), + ) + } else { + Box( + modifier = + Modifier + .aspectRatio(AspectRatios.WIDE) + .fillMaxSize() + .background(background), + ) { + Text( + text = 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 studio = + Studio( + UUID.randomUUID(), + "Adventure", + null, + ) + StudioCard( + studio = studio, + onClick = {}, + onLongClick = {}, + modifier = Modifier.width(180.dp), + ) + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt index 95926eed..e881bb98 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/FilterByButton.kt @@ -46,6 +46,7 @@ import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.StudioFilter import com.github.damontecres.wholphin.data.filter.VideoTypeFilter import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -206,7 +207,9 @@ fun FilterByButton( val isSelected = remember(currentValue) { when (filterOption) { - GenreFilter -> { + GenreFilter, + StudioFilter, + -> { (currentValue as? List) .orEmpty() .contains(value.value) @@ -271,7 +274,9 @@ fun FilterByButton( onClick = { val newFilter = when (filterOption) { - GenreFilter -> { + GenreFilter, + StudioFilter, + -> { val list = (currentValue as? List).orEmpty() val newValue = list diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt index e85b1d02..21ac519d 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/GenreCardGrid.kt @@ -139,6 +139,7 @@ class GenreViewModel nameLessThan = letter.toString(), limit = 0, enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, ) val result by GetGenresRequestHandler.execute(api, request) return@withContext result.totalRecordCount diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt new file mode 100644 index 00000000..b52c7a8e --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/components/StudioCardGrid.kt @@ -0,0 +1,220 @@ +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.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.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +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.ServerRepository +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.createStudioDestination +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.StudioCard +import com.github.damontecres.wholphin.ui.detail.CardGrid +import com.github.damontecres.wholphin.ui.detail.CardGridItem +import com.github.damontecres.wholphin.ui.setValueOnMain +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.GetStudiosRequestHandler +import com.github.damontecres.wholphin.util.LoadingExceptionHandler +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.userLibraryApi +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.api.ImageType +import org.jellyfin.sdk.model.api.request.GetStudiosRequest +import java.util.UUID + +@HiltViewModel(assistedFactory = StudioViewModel.Factory::class) +class StudioViewModel + @AssistedInject + constructor( + private val api: ApiClient, + private val imageUrlService: ImageUrlService, + private val serverRepository: ServerRepository, + val navigationManager: NavigationManager, + @Assisted private val itemId: UUID, + @Assisted private val includeItemTypes: List?, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create( + itemId: UUID, + includeItemTypes: List?, + ): StudioViewModel + } + + val item = MutableLiveData(null) + val loading = MutableLiveData(LoadingState.Pending) + val studios = MutableLiveData>(listOf()) + + fun init(cardWidthPx: Int) { + loading.value = LoadingState.Loading + viewModelScope.launch(Dispatchers.IO + LoadingExceptionHandler(loading, "Failed to fetch genres")) { + val item = + api.userLibraryApi.getItem(itemId = itemId).content.let { + BaseItem(it, false) + } + this@StudioViewModel.item.setValueOnMain(item) + val request = + GetStudiosRequest( + userId = serverRepository.currentUser.value?.id, + parentId = itemId, + fields = SlimItemFields, + includeItemTypes = includeItemTypes, + ) + val studios = + GetStudiosRequestHandler + .execute(api, request) + .content.items + .map { + val imageUrl = + imageUrlService.getItemImageUrl( + itemId = it.id, + imageType = ImageType.THUMB, + fillWidth = cardWidthPx, + ) + Studio(it.id, it.name ?: "", imageUrl) + } + withContext(Dispatchers.Main) { + this@StudioViewModel.studios.value = studios + loading.value = LoadingState.Success + } + } + } + + suspend fun positionOfLetter(letter: Char): Int = + withContext(Dispatchers.IO) { + val request = + GetStudiosRequest( + userId = serverRepository.currentUser.value?.id, + parentId = itemId, + nameLessThan = letter.toString(), + limit = 0, + enableTotalRecordCount = true, + includeItemTypes = includeItemTypes, + ) + val result by GetStudiosRequestHandler.execute(api, request) + return@withContext result.totalRecordCount + } + } + +@Stable +data class Studio( + val id: UUID, + val name: String, + val imageUrl: String?, +) : CardGridItem { + override val gridId: String get() = id.toString() + override val playable: Boolean = false + override val sortName: String get() = name +} + +@Composable +fun StudioCardGrid( + itemId: UUID, + includeItemTypes: List?, + modifier: Modifier = Modifier, + viewModel: StudioViewModel = + hiltViewModel( + creationCallback = { it.create(itemId, includeItemTypes) }, + ), +) { + val columns = 4 + val spacing = 16.dp + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val cardWidthPx = + remember { + with(density) { + // Grid has 16dp padding on either side & 16dp spacing between 4 cards + // This isn't exact though because it doesn't account for nav drawer or letters, but it's close and the calculation is much faster + // E.g. on 1080p, this results in 440px versus 395px actual, so only minimal scaling down is required + (configuration.screenWidthDp.dp - (2 * 16.dp + 3 * spacing)) + .div(columns) + .roundToPx() + } + } + OneTimeLaunchedEffect { + viewModel.init(cardWidthPx) + } + val loading by viewModel.loading.observeAsState(LoadingState.Pending) + val studios by viewModel.studios.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() } + val item by viewModel.item.observeAsState(null) + CardGrid( + pager = studios, + onClickItem = { _, studio -> + viewModel.navigationManager.navigateTo( + createStudioDestination( + studioId = studio.id, + name = studio.name, + parentId = itemId, + parentName = item?.title, + includeItemTypes = includeItemTypes, + ), + ) + }, + onLongClickItem = { _, _ -> }, + onClickPlay = { _, _ -> }, + letterPosition = { viewModel.positionOfLetter(it) }, + gridFocusRequester = gridFocusRequester, + showJumpButtons = false, + showLetterButtons = true, + modifier = Modifier.fillMaxSize(), + initialPosition = 0, + positionCallback = { columns, position -> + }, + columns = columns, + spacing = spacing, + cardContent = { item: Studio?, onClick: () -> Unit, onLongClick: () -> Unit, mod: Modifier -> + StudioCard( + studio = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt index 5cf0789e..8e7c89b1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/data/ItemDetailsDialogInfo.kt @@ -40,6 +40,7 @@ data class ItemDetailsDialogInfo( val overview: String?, val genres: List, val files: List, + val studios: List = emptyList(), ) /** @@ -74,6 +75,12 @@ fun ItemDetailsDialog( text = info.title, style = MaterialTheme.typography.headlineSmall, ) + if (info.studios.isNotEmpty()) { + Text( + text = info.studios.joinToString(", "), + style = MaterialTheme.typography.titleMedium, + ) + } if (info.genres.isNotEmpty()) { Text( text = info.genres.joinToString(", "), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt index 68b9dd18..cbc87bae 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/CollectionFolderTv.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.data.filter.DefaultTvFilterOptions import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter @@ -29,6 +30,7 @@ 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.components.StudioCardGrid import com.github.damontecres.wholphin.ui.components.TabRow import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.data.SeriesSortOptions @@ -53,6 +55,7 @@ fun CollectionFolderTv( stringResource(R.string.recommended), stringResource(R.string.library), stringResource(R.string.genres), + stringResource(R.string.studios), ) var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } val focusRequester = remember { FocusRequester() } @@ -123,6 +126,7 @@ fun CollectionFolderTv( showTitle = false, recursive = true, sortOptions = SeriesSortOptions, + filterOptions = DefaultTvFilterOptions, defaultViewOptions = ViewOptionsPoster, modifier = Modifier @@ -151,6 +155,18 @@ fun CollectionFolderTv( ) } + // Studios + 3 -> { + StudioCardGrid( + itemId = destination.itemId, + includeItemTypes = listOf(BaseItemKind.SERIES), + modifier = + Modifier + .fillMaxSize() + .focusRequester(focusRequester), + ) + } + else -> { ErrorMessage("Invalid tab index $selectedTabIndex", null) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 31e6233c..440e8d4a 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -35,6 +35,7 @@ import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.aspectRatioFloat +import com.github.damontecres.wholphin.data.model.studioNames import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.AspectRatios @@ -185,6 +186,7 @@ fun MovieDetails( overview = movie.data.overview, genres = movie.data.genres.orEmpty(), files = movie.data.mediaSources.orEmpty(), + studios = movie.studioNames, ) }, moreOnClick = { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 0e1e6a8b..91baa312 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -41,12 +41,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.data.model.studioNames import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.ui.Cards @@ -213,6 +215,7 @@ fun SeriesDetails( title = item.name ?: context.getString(R.string.unknown), overview = item.data.overview, genres = item.data.genres.orEmpty(), + studios = item.studioNames, files = listOf(), ) }, @@ -397,6 +400,7 @@ fun SeriesDetailsContent( series = series, showLogo = preferences.appPreferences.interfacePreferences.showLogos, overviewOnClick = overviewOnClick, + bringIntoViewRequester = bringIntoViewRequester, modifier = Modifier .fillMaxWidth() @@ -686,6 +690,7 @@ fun SeriesDetailsHeader( series: BaseItem, showLogo: Boolean, overviewOnClick: () -> Unit, + bringIntoViewRequester: BringIntoViewRequester, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -711,8 +716,16 @@ fun SeriesDetailsHeader( null, Modifier.padding(start = HeaderUtils.startPadding), ) + dto.studios?.let { + val studios = remember { series.studioNames } + GenreText( + studios, + textStyle = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(start = HeaderUtils.startPadding), + ) + } dto.genres?.letNotEmpty { - GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 8.dp)) + GenreText(it, Modifier.padding(start = HeaderUtils.startPadding, bottom = 4.dp)) } dto.overview?.let { overview -> OverviewText( @@ -720,6 +733,14 @@ fun SeriesDetailsHeader( maxLines = 3, onClick = overviewOnClick, textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt index 9addbb0e..b9608dcc 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/HomePage.kt @@ -54,6 +54,7 @@ import com.github.damontecres.wholphin.ui.cards.BannerCard import com.github.damontecres.wholphin.ui.cards.BannerCardWithTitle import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.StudioCard import com.github.damontecres.wholphin.ui.components.CircularProgress import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.DialogParams @@ -535,6 +536,17 @@ fun HomePageCardContent( ) } + BaseItemKind.STUDIO -> { + StudioCard( + studioId = item.id, + name = item.name, + imageUrl = item.imageUrlOverride, + onClick = onClick, + onLongClick = onLongClick, + modifier = modifier.height(viewOptions.heightDp.dp), + ) + } + else -> { val imageType = remember(item, viewOptions) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt index 4236c03a..8c603911 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeLibraryRowTypeList.kt @@ -80,6 +80,16 @@ fun getSupportedRowTypes(library: Library): List { ) } + library.collectionType == CollectionType.TVSHOWS -> { + listOf( + LibraryRowType.RECENTLY_ADDED, + LibraryRowType.RECENTLY_RELEASED, + LibraryRowType.SUGGESTIONS, + LibraryRowType.GENRES, + LibraryRowType.STUDIOS, + ) + } + supportsSuggestions -> { listOf( LibraryRowType.RECENTLY_ADDED, @@ -124,6 +134,7 @@ enum class LibraryRowType( RECENTLY_RELEASED(R.string.recently_released), SUGGESTIONS(R.string.suggestions), GENRES(R.string.genres), + STUDIOS(R.string.studios), TV_CHANNELS(R.string.channels), TV_PROGRAMS(R.string.live_tv), RECENTLY_RECORDED(R.string.recently_recorded), diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt index b3997bf5..c4874aa0 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/main/settings/HomeSettingsViewModel.kt @@ -323,6 +323,16 @@ class HomeSettingsViewModel ) } + LibraryRowType.STUDIOS -> { + val title = + library.name.let { context.getString(R.string.studios_in, it) } + HomeRowConfigDisplay( + id = id, + title = title, + config = HomeRowConfig.Studios(library.itemId), + ) + } + LibraryRowType.SUGGESTIONS -> { val title = library.name.let { context.getString(R.string.suggestions_for, it) } @@ -700,6 +710,10 @@ class HomeSettingsViewModel it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) } + is HomeRowConfig.Studios -> { + it.config.updateViewOptions(it.config.viewOptions.copy(heightDp = preset.genreSize)) + } + is HomeRowConfig.GetItems -> { it.config } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index df6053ad..a7559914 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -99,6 +99,7 @@ sealed class Destination( @Serializable data class FilteredCollection( val itemId: UUID, + val parentType: BaseItemKind, val filter: CollectionFolderFilter, val recursive: Boolean, ) : Destination(false) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index ca80bb66..1aa92bb3 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions +import com.github.damontecres.wholphin.data.filter.DefaultForStudiosFilterOptions import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid @@ -252,7 +253,12 @@ fun DestinationContent( recursive = destination.recursive, usePosters = true, playEnabled = true, // TODO only genres use this currently, so might need to change in future - filterOptions = DefaultForGenresFilterOptions, + filterOptions = + when (destination.parentType) { + BaseItemKind.GENRE -> DefaultForGenresFilterOptions + BaseItemKind.STUDIO -> DefaultForStudiosFilterOptions + else -> throw IllegalArgumentException("Unsupported parentType ${destination.parentType}") + }, modifier = modifier, ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt index 5358a14e..a39cae25 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/util/FilterUtils.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.filter.GenreFilter import com.github.damontecres.wholphin.data.filter.ItemFilterBy import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.PlayedFilter +import com.github.damontecres.wholphin.data.filter.StudioFilter import com.github.damontecres.wholphin.data.filter.VideoTypeFilter import com.github.damontecres.wholphin.data.filter.YearFilter import kotlinx.coroutines.Dispatchers @@ -16,7 +17,9 @@ import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.localizationApi +import org.jellyfin.sdk.api.client.extensions.studiosApi import org.jellyfin.sdk.api.client.extensions.yearsApi +import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.SortOrder import timber.log.Timber @@ -47,6 +50,16 @@ object FilterUtils { .map { FilterValueOption(it.name ?: "", it.id) } } + StudioFilter -> { + api.studiosApi + .getStudios( + parentId = parentId, + userId = userId, + includeItemTypes = listOf(BaseItemKind.SERIES), + ).content.items + .map { FilterValueOption(it.name ?: "", it.id) } + } + FavoriteFilter, PlayedFilter, -> { diff --git a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt index f57dfa46..a64acb70 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/util/ApiRequestPager.kt @@ -12,6 +12,7 @@ import org.jellyfin.sdk.api.client.extensions.itemsApi import org.jellyfin.sdk.api.client.extensions.liveTvApi import org.jellyfin.sdk.api.client.extensions.personsApi import org.jellyfin.sdk.api.client.extensions.playlistsApi +import org.jellyfin.sdk.api.client.extensions.studiosApi import org.jellyfin.sdk.api.client.extensions.suggestionsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi @@ -24,6 +25,7 @@ import org.jellyfin.sdk.model.api.request.GetNextUpRequest import org.jellyfin.sdk.model.api.request.GetPersonsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest +import org.jellyfin.sdk.model.api.request.GetStudiosRequest import org.jellyfin.sdk.model.api.request.GetSuggestionsRequest import timber.log.Timber import java.util.UUID @@ -306,3 +308,23 @@ val GetPersonsHandler = request: GetPersonsRequest, ): Response = api.personsApi.getPersons((request)) } + +val GetStudiosRequestHandler = + object : RequestHandler { + override fun prepare( + request: GetStudiosRequest, + startIndex: Int, + limit: Int, + enableTotalRecordCount: Boolean, + ): GetStudiosRequest = + request.copy( + startIndex = startIndex, + limit = limit, + enableTotalRecordCount = enableTotalRecordCount, + ) + + override suspend fun execute( + api: ApiClient, + request: GetStudiosRequest, + ): Response = api.studiosApi.getStudios(request) + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c41b5511..46afd62a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -752,5 +752,6 @@ View more Discover TV Shows Discover Movies + Studios in %1$s diff --git a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt index 84aa72b6..23ab54d5 100644 --- a/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt +++ b/app/src/test/java/com/github/damontecres/wholphin/test/TestHomeRowSamples.kt @@ -43,6 +43,7 @@ class TestHomeRowSamples { parentId = UUID.randomUUID(), viewOptions = HomeRowViewOptions(), ), + HomeRowConfig.Studios(parentId = UUID.randomUUID()), HomeRowConfig.ContinueWatching( viewOptions = HomeRowViewOptions(), ), @@ -98,6 +99,7 @@ class TestHomeRowSamples { is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) is HomeRowConfig.Suggestions -> foundTypes.add(it::class) is HomeRowConfig.TvChannels -> foundTypes.add(it::class) + is HomeRowConfig.Studios -> foundTypes.add(it::class) } } Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)