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, DecadeFilter,
) )
val DefaultTvFilterOptions =
listOf(
PlayedFilter,
FavoriteFilter,
GenreFilter,
StudioFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
val DefaultForFavoritesFilterOptions = val DefaultForFavoritesFilterOptions =
listOf( listOf(
PlayedFilter, PlayedFilter,
@ -32,6 +45,19 @@ val DefaultForGenresFilterOptions =
listOf( listOf(
PlayedFilter, PlayedFilter,
FavoriteFilter, FavoriteFilter,
StudioFilter,
CommunityRatingFilter,
OfficialRatingFilter,
VideoTypeFilter,
YearFilter,
DecadeFilter,
)
val DefaultForStudiosFilterOptions =
listOf(
PlayedFilter,
FavoriteFilter,
GenreFilter,
CommunityRatingFilter, CommunityRatingFilter,
OfficialRatingFilter, OfficialRatingFilter,
VideoTypeFilter, VideoTypeFilter,
@ -183,3 +209,16 @@ data object CommunityRatingFilter : ItemFilterBy<Int> {
filter: GetItemsFilter, filter: GetItemsFilter,
): GetItemsFilter = filter.copy(minCommunityRating = value?.toDouble()) ): 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>?, includeItemTypes: List<BaseItemKind>?,
) = Destination.FilteredCollection( ) = Destination.FilteredCollection(
itemId = parentId, itemId = parentId,
parentType = BaseItemKind.GENRE,
filter = filter =
CollectionFolderFilter( CollectionFolderFilter(
nameOverride = nameOverride =
@ -286,3 +287,31 @@ fun createGenreDestination(
), ),
recursive = true, 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) 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 * 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.HomeRowConfig
import com.github.damontecres.wholphin.data.model.SUPPORTED_HOME_PAGE_SETTINGS_VERSION 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.createGenreDestination
import com.github.damontecres.wholphin.data.model.createStudioDestination
import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration import com.github.damontecres.wholphin.preferences.DefaultUserConfiguration
import com.github.damontecres.wholphin.preferences.HomePagePreferences import com.github.damontecres.wholphin.preferences.HomePagePreferences
import com.github.damontecres.wholphin.ui.DefaultItemFields 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.GetGenresRequestHandler
import com.github.damontecres.wholphin.util.GetItemsRequestHandler import com.github.damontecres.wholphin.util.GetItemsRequestHandler
import com.github.damontecres.wholphin.util.GetPersonsHandler 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
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error import com.github.damontecres.wholphin.util.HomeRowLoadingState.Error
import com.github.damontecres.wholphin.util.HomeRowLoadingState.Success 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.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest import org.jellyfin.sdk.model.api.request.GetRecommendedProgramsRequest
import org.jellyfin.sdk.model.api.request.GetRecordingsRequest import org.jellyfin.sdk.model.api.request.GetRecordingsRequest
import org.jellyfin.sdk.model.api.request.GetStudiosRequest
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject 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 -> { is HomeRowConfig.GetItems -> {
HomeRowConfigDisplay(id, config.name, config) 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 -> { is HomeRowConfig.RecentlyAdded -> {
val library = val library =
libraries 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.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter 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.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.filter.YearFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.GetItemsFilter
@ -206,7 +207,9 @@ fun FilterByButton(
val isSelected = val isSelected =
remember(currentValue) { remember(currentValue) {
when (filterOption) { when (filterOption) {
GenreFilter -> { GenreFilter,
StudioFilter,
-> {
(currentValue as? List<UUID>) (currentValue as? List<UUID>)
.orEmpty() .orEmpty()
.contains(value.value) .contains(value.value)
@ -271,7 +274,9 @@ fun FilterByButton(
onClick = { onClick = {
val newFilter = val newFilter =
when (filterOption) { when (filterOption) {
GenreFilter -> { GenreFilter,
StudioFilter,
-> {
val list = (currentValue as? List<UUID>).orEmpty() val list = (currentValue as? List<UUID>).orEmpty()
val newValue = val newValue =
list list

View file

@ -139,6 +139,7 @@ class GenreViewModel
nameLessThan = letter.toString(), nameLessThan = letter.toString(),
limit = 0, limit = 0,
enableTotalRecordCount = true, enableTotalRecordCount = true,
includeItemTypes = includeItemTypes,
) )
val result by GetGenresRequestHandler.execute(api, request) val result by GetGenresRequestHandler.execute(api, request)
return@withContext result.totalRecordCount 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 overview: String?,
val genres: List<String>, val genres: List<String>,
val files: List<MediaSourceInfo>, val files: List<MediaSourceInfo>,
val studios: List<String> = emptyList(),
) )
/** /**
@ -74,6 +75,12 @@ fun ItemDetailsDialog(
text = info.title, text = info.title,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
) )
if (info.studios.isNotEmpty()) {
Text(
text = info.studios.joinToString(", "),
style = MaterialTheme.typography.titleMedium,
)
}
if (info.genres.isNotEmpty()) { if (info.genres.isNotEmpty()) {
Text( Text(
text = info.genres.joinToString(", "), text = info.genres.joinToString(", "),

View file

@ -21,6 +21,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import com.github.damontecres.wholphin.R 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.BaseItem
import com.github.damontecres.wholphin.data.model.CollectionFolderFilter import com.github.damontecres.wholphin.data.model.CollectionFolderFilter
import com.github.damontecres.wholphin.data.model.GetItemsFilter 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.ErrorMessage
import com.github.damontecres.wholphin.ui.components.GenreCardGrid import com.github.damontecres.wholphin.ui.components.GenreCardGrid
import com.github.damontecres.wholphin.ui.components.RecommendedTvShow 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.TabRow
import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster import com.github.damontecres.wholphin.ui.components.ViewOptionsPoster
import com.github.damontecres.wholphin.ui.data.SeriesSortOptions import com.github.damontecres.wholphin.ui.data.SeriesSortOptions
@ -53,6 +55,7 @@ fun CollectionFolderTv(
stringResource(R.string.recommended), stringResource(R.string.recommended),
stringResource(R.string.library), stringResource(R.string.library),
stringResource(R.string.genres), stringResource(R.string.genres),
stringResource(R.string.studios),
) )
var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) } var selectedTabIndex by rememberSaveable { mutableIntStateOf(rememberedTabIndex) }
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
@ -123,6 +126,7 @@ fun CollectionFolderTv(
showTitle = false, showTitle = false,
recursive = true, recursive = true,
sortOptions = SeriesSortOptions, sortOptions = SeriesSortOptions,
filterOptions = DefaultTvFilterOptions,
defaultViewOptions = ViewOptionsPoster, defaultViewOptions = ViewOptionsPoster,
modifier = modifier =
Modifier Modifier
@ -151,6 +155,18 @@ fun CollectionFolderTv(
) )
} }
// Studios
3 -> {
StudioCardGrid(
itemId = destination.itemId,
includeItemTypes = listOf(BaseItemKind.SERIES),
modifier =
Modifier
.fillMaxSize()
.focusRequester(focusRequester),
)
}
else -> { else -> {
ErrorMessage("Invalid tab index $selectedTabIndex", null) 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.Person
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.aspectRatioFloat 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.AspectRatios import com.github.damontecres.wholphin.ui.AspectRatios
@ -185,6 +186,7 @@ fun MovieDetails(
overview = movie.data.overview, overview = movie.data.overview,
genres = movie.data.genres.orEmpty(), genres = movie.data.genres.orEmpty(),
files = movie.data.mediaSources.orEmpty(), files = movie.data.mediaSources.orEmpty(),
studios = movie.studioNames,
) )
}, },
moreOnClick = { moreOnClick = {

View file

@ -41,12 +41,14 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.tv.material3.MaterialTheme
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ExtrasItem
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Person
import com.github.damontecres.wholphin.data.model.Trailer 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.preferences.UserPreferences
import com.github.damontecres.wholphin.services.TrailerService import com.github.damontecres.wholphin.services.TrailerService
import com.github.damontecres.wholphin.ui.Cards import com.github.damontecres.wholphin.ui.Cards
@ -213,6 +215,7 @@ fun SeriesDetails(
title = item.name ?: context.getString(R.string.unknown), title = item.name ?: context.getString(R.string.unknown),
overview = item.data.overview, overview = item.data.overview,
genres = item.data.genres.orEmpty(), genres = item.data.genres.orEmpty(),
studios = item.studioNames,
files = listOf(), files = listOf(),
) )
}, },
@ -397,6 +400,7 @@ fun SeriesDetailsContent(
series = series, series = series,
showLogo = preferences.appPreferences.interfacePreferences.showLogos, showLogo = preferences.appPreferences.interfacePreferences.showLogos,
overviewOnClick = overviewOnClick, overviewOnClick = overviewOnClick,
bringIntoViewRequester = bringIntoViewRequester,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@ -686,6 +690,7 @@ fun SeriesDetailsHeader(
series: BaseItem, series: BaseItem,
showLogo: Boolean, showLogo: Boolean,
overviewOnClick: () -> Unit, overviewOnClick: () -> Unit,
bringIntoViewRequester: BringIntoViewRequester,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -711,8 +716,16 @@ fun SeriesDetailsHeader(
null, null,
Modifier.padding(start = HeaderUtils.startPadding), 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 { 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 -> dto.overview?.let { overview ->
OverviewText( OverviewText(
@ -720,6 +733,14 @@ fun SeriesDetailsHeader(
maxLines = 3, maxLines = 3,
onClick = overviewOnClick, onClick = overviewOnClick,
textBoxHeight = Dp.Unspecified, 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.BannerCardWithTitle
import com.github.damontecres.wholphin.ui.cards.GenreCard import com.github.damontecres.wholphin.ui.cards.GenreCard
import com.github.damontecres.wholphin.ui.cards.ItemRow 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.CircularProgress
import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog import com.github.damontecres.wholphin.ui.components.ConfirmDeleteDialog
import com.github.damontecres.wholphin.ui.components.DialogParams 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 -> { else -> {
val imageType = val imageType =
remember(item, viewOptions) { 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 -> { supportsSuggestions -> {
listOf( listOf(
LibraryRowType.RECENTLY_ADDED, LibraryRowType.RECENTLY_ADDED,
@ -124,6 +134,7 @@ enum class LibraryRowType(
RECENTLY_RELEASED(R.string.recently_released), RECENTLY_RELEASED(R.string.recently_released),
SUGGESTIONS(R.string.suggestions), SUGGESTIONS(R.string.suggestions),
GENRES(R.string.genres), GENRES(R.string.genres),
STUDIOS(R.string.studios),
TV_CHANNELS(R.string.channels), TV_CHANNELS(R.string.channels),
TV_PROGRAMS(R.string.live_tv), TV_PROGRAMS(R.string.live_tv),
RECENTLY_RECORDED(R.string.recently_recorded), 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 -> { LibraryRowType.SUGGESTIONS -> {
val title = val title =
library.name.let { context.getString(R.string.suggestions_for, it) } 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)) 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 -> { is HomeRowConfig.GetItems -> {
it.config it.config
} }

View file

@ -99,6 +99,7 @@ sealed class Destination(
@Serializable @Serializable
data class FilteredCollection( data class FilteredCollection(
val itemId: UUID, val itemId: UUID,
val parentType: BaseItemKind,
val filter: CollectionFolderFilter, val filter: CollectionFolderFilter,
val recursive: Boolean, val recursive: Boolean,
) : Destination(false) ) : Destination(false)

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions 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.data.model.SeerrItemType
import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.ItemGrid
@ -252,7 +253,12 @@ fun DestinationContent(
recursive = destination.recursive, recursive = destination.recursive,
usePosters = true, usePosters = true,
playEnabled = true, // TODO only genres use this currently, so might need to change in future 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, 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.ItemFilterBy
import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter import com.github.damontecres.wholphin.data.filter.OfficialRatingFilter
import com.github.damontecres.wholphin.data.filter.PlayedFilter 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.VideoTypeFilter
import com.github.damontecres.wholphin.data.filter.YearFilter import com.github.damontecres.wholphin.data.filter.YearFilter
import kotlinx.coroutines.Dispatchers 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.ApiClient
import org.jellyfin.sdk.api.client.extensions.genresApi import org.jellyfin.sdk.api.client.extensions.genresApi
import org.jellyfin.sdk.api.client.extensions.localizationApi 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.api.client.extensions.yearsApi
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemSortBy import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.SortOrder
import timber.log.Timber import timber.log.Timber
@ -47,6 +50,16 @@ object FilterUtils {
.map { FilterValueOption(it.name ?: "", it.id) } .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, FavoriteFilter,
PlayedFilter, 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.liveTvApi
import org.jellyfin.sdk.api.client.extensions.personsApi import org.jellyfin.sdk.api.client.extensions.personsApi
import org.jellyfin.sdk.api.client.extensions.playlistsApi 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.suggestionsApi
import org.jellyfin.sdk.api.client.extensions.tvShowsApi import org.jellyfin.sdk.api.client.extensions.tvShowsApi
import org.jellyfin.sdk.api.client.extensions.userLibraryApi 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.GetPersonsRequest
import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest import org.jellyfin.sdk.model.api.request.GetPlaylistItemsRequest
import org.jellyfin.sdk.model.api.request.GetResumeItemsRequest 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 org.jellyfin.sdk.model.api.request.GetSuggestionsRequest
import timber.log.Timber import timber.log.Timber
import java.util.UUID import java.util.UUID
@ -306,3 +308,23 @@ val GetPersonsHandler =
request: GetPersonsRequest, request: GetPersonsRequest,
): Response<BaseItemDtoQueryResult> = api.personsApi.getPersons((request)) ): 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="view_more">View more</string>
<string name="discover_tv">Discover TV Shows</string> <string name="discover_tv">Discover TV Shows</string>
<string name="discover_movies">Discover Movies</string> <string name="discover_movies">Discover Movies</string>
<string name="studios_in">Studios in %1$s</string>
</resources> </resources>

View file

@ -43,6 +43,7 @@ class TestHomeRowSamples {
parentId = UUID.randomUUID(), parentId = UUID.randomUUID(),
viewOptions = HomeRowViewOptions(), viewOptions = HomeRowViewOptions(),
), ),
HomeRowConfig.Studios(parentId = UUID.randomUUID()),
HomeRowConfig.ContinueWatching( HomeRowConfig.ContinueWatching(
viewOptions = HomeRowViewOptions(), viewOptions = HomeRowViewOptions(),
), ),
@ -98,6 +99,7 @@ class TestHomeRowSamples {
is HomeRowConfig.TvPrograms -> foundTypes.add(it::class) is HomeRowConfig.TvPrograms -> foundTypes.add(it::class)
is HomeRowConfig.Suggestions -> foundTypes.add(it::class) is HomeRowConfig.Suggestions -> foundTypes.add(it::class)
is HomeRowConfig.TvChannels -> 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) Assert.assertEquals(HomeRowConfig::class.sealedSubclasses.size, foundTypes.size)