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
This commit is contained in:
Ray 2026-04-07 16:03:58 -04:00 committed by GitHub
parent edaf06afd0
commit ade564d907
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 651 additions and 4 deletions

View file

@ -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<Int> {
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble())
}
data object StudioFilter : ItemFilterBy<List<UUID>> {
override val stringRes: Int = R.string.studios
override val supportMultiple: Boolean = true
override fun get(filter: GetItemsFilter): List<UUID>? = filter.studios
override fun set(
value: List<UUID>?,
filter: GetItemsFilter,
): GetItemsFilter = filter.copy(studios = value)
}

View file

@ -270,6 +270,7 @@ fun createGenreDestination(
includeItemTypes: List<BaseItemKind>?,
) = 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<BaseItemKind>?,
) = 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()

View file

@ -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
*/

View file

@ -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

View file

@ -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),
)
}
}

View file

@ -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<UUID>)
.orEmpty()
.contains(value.value)
@ -271,7 +274,9 @@ fun FilterByButton(
onClick = {
val newFilter =
when (filterOption) {
GenreFilter -> {
GenreFilter,
StudioFilter,
-> {
val list = (currentValue as? List<UUID>).orEmpty()
val newValue =
list

View file

@ -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

View file

@ -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<BaseItemKind>?,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(
itemId: UUID,
includeItemTypes: List<BaseItemKind>?,
): StudioViewModel
}
val item = MutableLiveData<BaseItem?>(null)
val loading = MutableLiveData<LoadingState>(LoadingState.Pending)
val studios = MutableLiveData<List<Studio>>(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<BaseItemKind>?,
modifier: Modifier = Modifier,
viewModel: StudioViewModel =
hiltViewModel<StudioViewModel, StudioViewModel.Factory>(
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,
)
},
)
}
}
}
}

View file

@ -40,6 +40,7 @@ data class ItemDetailsDialogInfo(
val overview: String?,
val genres: List<String>,
val files: List<MediaSourceInfo>,
val studios: List<String> = 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(", "),

View file

@ -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)
}

View file

@ -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 = {

View file

@ -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()
}
}
},
)
}
}

View file

@ -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) {

View file

@ -80,6 +80,16 @@ fun getSupportedRowTypes(library: Library): List<LibraryRowType> {
)
}
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),

View file

@ -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
}

View file

@ -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)

View file

@ -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,
)
}

View file

@ -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,
-> {

View file

@ -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<BaseItemDtoQueryResult> = api.personsApi.getPersons((request))
}
val GetStudiosRequestHandler =
object : RequestHandler<GetStudiosRequest> {
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<BaseItemDtoQueryResult> = api.studiosApi.getStudios(request)
}

View file

@ -752,5 +752,6 @@
<string name="view_more">View more</string>
<string name="discover_tv">Discover TV Shows</string>
<string name="discover_movies">Discover Movies</string>
<string name="studios_in">Studios in %1$s</string>
</resources>