mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 23:51:21 +02:00
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  ### Filter drop down for studios  ### Updates series details Shows the studio above the genres  ## AI or LLM usage None
This commit is contained in:
parent
edaf06afd0
commit
ade564d907
21 changed files with 651 additions and 4 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(", "),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
-> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue