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)