From cf9d0d5286486ec0586beea146ff080be0bac4c1 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 23 Dec 2025 19:12:58 -0500 Subject: [PATCH] Add basic tv series discover page --- .../wholphin/data/model/DiscoverItem.kt | 21 +- .../detail/discover/DiscoverMovieDetails.kt | 2 - .../detail/discover/DiscoverSeriesDetails.kt | 517 ++++++++++++++++++ .../discover/DiscoverSeriesViewModel.kt | 177 ++++++ .../wholphin/ui/nav/DestinationContent.kt | 17 +- .../wholphin/ui/seerr/SeerrDiscoverPage.kt | 4 +- 6 files changed, 731 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt diff --git a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt index e804f900..e5e97a50 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -5,7 +5,9 @@ package com.github.damontecres.wholphin.data.model import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvResult +import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response import com.github.damontecres.wholphin.services.SeerrSearchResult import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -104,7 +106,20 @@ data class DiscoverItem( constructor(tv: TvResult) : this( id = tv.id!!, - type = SeerrItemType.MOVIE, + type = SeerrItemType.TV, + title = tv.name, + subtitle = null, + overview = tv.overview, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = tv.firstAirDate, + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + jellyfinItemId = tv.mediaInfo?.jellyfinMediaId?.toUUIDOrNull(), + ) + + constructor(tv: TvDetails) : this( + id = tv.id!!, + type = SeerrItemType.TV, title = tv.name, subtitle = null, overview = tv.overview, @@ -139,4 +154,8 @@ data class DiscoverRating( criticRating = rating.criticsScore, audienceRating = rating.audienceScore?.div(10f), ) + constructor(rating: TvTvIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = null, + ) } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt index 8e45402a..4a099c0e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -54,7 +54,6 @@ import com.github.damontecres.wholphin.ui.components.DialogParams import com.github.damontecres.wholphin.ui.components.DialogPopup import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.components.LoadingPage -import com.github.damontecres.wholphin.ui.data.AddPlaylistViewModel import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo import com.github.damontecres.wholphin.ui.detail.MoreDialogActions @@ -77,7 +76,6 @@ fun DiscoverMovieDetails( hiltViewModel( creationCallback = { it.create(destination.item) }, ), - playlistViewModel: AddPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current LifecycleResumeEffect(Unit) { diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt new file mode 100644 index 00000000..3e69102d --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesDetails.kt @@ -0,0 +1,517 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.SeerrAvailability +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.TrailerService +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard +import com.github.damontecres.wholphin.ui.cards.ItemRow +import com.github.damontecres.wholphin.ui.cards.PersonRow +import com.github.damontecres.wholphin.ui.components.DialogItem +import com.github.damontecres.wholphin.ui.components.DialogParams +import com.github.damontecres.wholphin.ui.components.DialogPopup +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.ErrorMessage +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.LoadingPage +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialog +import com.github.damontecres.wholphin.ui.data.ItemDetailsDialogInfo +import com.github.damontecres.wholphin.ui.detail.movie.TrailerRow +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.ui.tryRequestFocus +import com.github.damontecres.wholphin.util.ExceptionHandler +import com.github.damontecres.wholphin.util.LoadingState +import kotlinx.coroutines.launch +import org.jellyfin.sdk.model.api.BaseItemKind +import org.jellyfin.sdk.model.serializer.toUUIDOrNull +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverSeriesDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverSeriesViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), +) { + val context = LocalContext.current + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + + val item by viewModel.tvSeries.observeAsState() + val seasons by viewModel.seasons.observeAsState(listOf()) + val people by viewModel.people.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val recommended by viewModel.recommended.observeAsState(listOf()) + + var overviewDialog by remember { mutableStateOf(null) } + var seasonDialog by remember { mutableStateOf(null) } + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { item -> + val rating by viewModel.rating.observeAsState(null) + DiscoverSeriesDetailsContent( + preferences = preferences, + series = item, + rating = rating, + seasons = seasons, + people = people, + similar = similar, + recommended = recommended, + modifier = modifier, + onClickItem = { index, item -> + viewModel.navigateTo(Destination.DiscoveredItem(item)) + }, + onClickPerson = { + viewModel.navigateTo( + Destination.MediaItem( + it.id, + BaseItemKind.PERSON, + ), + ) + }, + goToOnClick = { + item.mediaInfo?.jellyfinMediaId?.toUUIDOrNull()?.let { + viewModel.navigateTo( + Destination.MediaItem( + itemId = it, + type = BaseItemKind.MOVIE, + ), + ) + } + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = item.name ?: context.getString(R.string.unknown), + overview = item.overview, + genres = item.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + trailers = listOf(), + requestOnClick = { + item.id?.let { viewModel.request(it) } + }, + cancelOnClick = { + item.id?.let { viewModel.cancelRequest(it) } + }, + moreOnClick = { + }, + onLongClickPerson = { _, _ -> }, + onLongClickSimilar = { _, _ -> }, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + seasonDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + waitToLoad = params.fromLongClick, + onDismissRequest = { seasonDialog = null }, + ) + } +} + +private const val HEADER_ROW = 0 +private const val SEASONS_ROW = HEADER_ROW + 1 +private const val PEOPLE_ROW = SEASONS_ROW + 1 +private const val TRAILER_ROW = PEOPLE_ROW + 1 +private const val EXTRAS_ROW = TRAILER_ROW + 1 +private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val RECOMMENDED_ROW = SIMILAR_ROW + 1 + +@Composable +fun DiscoverSeriesDetailsContent( + preferences: UserPreferences, + series: TvDetails, + rating: DiscoverRating?, + seasons: List, + similar: List, + recommended: List, + trailers: List, + people: List, + requestOnClick: () -> Unit, + cancelOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + goToOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, DiscoverItem) -> Unit, + onClickPerson: (Person) -> Unit, + onLongClickPerson: (Int, Person) -> Unit, + onLongClickSimilar: (Int, DiscoverItem) -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val bringIntoViewRequester = remember { BringIntoViewRequester() } + + var position by rememberInt() + val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val playFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + var moreDialog by remember { mutableStateOf(null) } + + Box( + modifier = modifier, + ) { + Column( + modifier = + Modifier + .padding(16.dp) + .fillMaxSize(), + ) { + LazyColumn( + contentPadding = PaddingValues(bottom = 80.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = Modifier, + ) { + item { + DiscoverSeriesDetailsHeader( + series = series, + rating = rating, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester) + .padding(top = 32.dp, bottom = 16.dp), + ) + ExpandableDiscoverButtons( + availability = + SeerrAvailability.from(series.mediaInfo?.status) + ?: SeerrAvailability.UNKNOWN, + requestOnClick = requestOnClick, + cancelOnClick = cancelOnClick, + moreOnClick = moreOnClick, + goToOnClick = goToOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } +// item { +// ItemRow( +// title = stringResource(R.string.tv_seasons) + " (${seasons.size})", +// items = seasons, +// onClickItem = { index, item -> +// position = SEASONS_ROW +// // onClickItem.invoke(index, item) +// }, +// onLongClickItem = { index, item -> +// position = SEASONS_ROW +// }, +// modifier = +// Modifier +// .fillMaxWidth() +// .focusRequester(focusRequesters[SEASONS_ROW]), +// cardContent = @Composable { index, item, mod, onClick, onLongClick -> +// SeasonCard( +// item = item, +// onClick = onClick, +// onLongClick = onLongClick, +// imageHeight = Cards.height2x3, +// imageWidth = Dp.Unspecified, +// showImageOverlay = true, +// modifier = mod, +// ) +// }, +// ) +// } + if (people.isNotEmpty()) { + item { + PersonRow( + people = people, + onClick = { + position = PEOPLE_ROW + onClickPerson.invoke(it) + }, + onLongClick = { index, person -> + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[PEOPLE_ROW]), + ) + } + } + if (trailers.isNotEmpty()) { + item { + TrailerRow( + trailers = trailers, + onClickTrailer = { + position = TRAILER_ROW + trailerOnClick.invoke(it) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[TRAILER_ROW]), + ) + } + } + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = { index, item -> + position = SIMILAR_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = SIMILAR_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = false, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + if (recommended.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.recommended), + items = similar, + onClickItem = { index, item -> + position = RECOMMENDED_ROW + onClickItem.invoke(index, item) + }, + onLongClickItem = { index, similar -> + position = RECOMMENDED_ROW + onLongClickSimilar.invoke(index, similar) + }, + cardContent = { index, item, mod, onClick, onLongClick -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + showOverlay = true, + modifier = mod, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[RECOMMENDED_ROW]), + ) + } + } + } + } + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +@Composable +fun DiscoverSeriesDetailsHeader( + series: TvDetails, + rating: DiscoverRating?, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = series.name ?: stringResource(R.string.unknown), + style = MaterialTheme.typography.displaySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(.75f), + ) + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth(.60f), + ) { + val padding = 4.dp + val details = + remember(series) { + buildList { + series.firstAirDate?.let(::add) + series.episodeRunTime + ?.average() + ?.takeIf { !it.isNaN() && it > 0 } + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + // TODO + } + } + + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = Modifier, + ) + series.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + series.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + ) + } + } + } +} + +fun buildDialogForSeason( + context: Context, + s: BaseItem, + onClickItem: (BaseItem) -> Unit, + markPlayed: (Boolean) -> Unit, + onClickPlay: (Boolean) -> Unit, +): DialogParams { + val items = + buildList { + add( + DialogItem(context.getString(R.string.go_to), Icons.Default.PlayArrow) { + onClickItem.invoke(s) + }, + ) + if (s.data.userData?.played == true) { + add( + DialogItem(context.getString(R.string.mark_unwatched), R.string.fa_eye) { + markPlayed.invoke(false) + }, + ) + } else { + add( + DialogItem(context.getString(R.string.mark_watched), R.string.fa_eye_slash) { + markPlayed.invoke(true) + }, + ) + } + add( + DialogItem( + context.getString(R.string.play), + Icons.Default.PlayArrow, + iconColor = Color.Green.copy(alpha = .8f), + ) { + onClickPlay.invoke(false) + }, + ) + add( + DialogItem( + context.getString(R.string.shuffle), + R.string.fa_shuffle, + ) { + onClickPlay.invoke(true) + }, + ) + } + return DialogParams( + title = s.name ?: context.getString(R.string.tv_season), + fromLongClick = true, + items = items, + ) +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt new file mode 100644 index 00000000..41f91f62 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverSeriesViewModel.kt @@ -0,0 +1,177 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import android.content.Context +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest +import com.github.damontecres.wholphin.api.seerr.model.Season +import com.github.damontecres.wholphin.api.seerr.model.TvDetails +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.Trailer +import com.github.damontecres.wholphin.services.BackdropService +import com.github.damontecres.wholphin.services.NavigationManager +import com.github.damontecres.wholphin.services.SeerrService +import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.setValueOnMain +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 dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.model.UUID +import org.jellyfin.sdk.model.api.PersonKind + +@HiltViewModel(assistedFactory = DiscoverSeriesViewModel.Factory::class) +class DiscoverSeriesViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + private val backdropService: BackdropService, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverSeriesViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val tvSeries = MutableLiveData(null) + val rating = MutableLiveData(null) + + val seasons = MutableLiveData>(listOf()) + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) + val recommended = MutableLiveData>(listOf()) + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = seerrService.api.tvApi.tvTvIdGet(tvId = item.id) + this@DiscoverSeriesViewModel.tvSeries.setValueOnMain(tv) + tv + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val tv = fetchAndSetItem().await() + val discoveredItem = DiscoverItem(tv) + backdropService.submit(discoveredItem) + + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + if (!similar.isInitialized) { + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdSimilarGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + viewModelScope.launchIO { + val result = + seerrService.api.moviesApi + .movieMovieIdRecommendationsGet(movieId = item.id, page = 2) + .results + ?.map(::DiscoverItem) + .orEmpty() + similar.setValueOnMain(result) + } + } + val people = + tv.credits + ?.cast + ?.map { + Person( + id = UUID.randomUUID(), + name = it.name, + role = it.character, + type = PersonKind.UNKNOWN, + imageUrl = "https://image.tmdb.org/t/p/w600_and_h900_bestv2${it.profilePath}", + favorite = false, + ) + }.orEmpty() + + tv.credits + ?.crew + ?.map { + Person( + id = UUID.randomUUID(), + name = it.name, + role = it.job, + type = PersonKind.UNKNOWN, + imageUrl = "https://image.tmdb.org/t/p/w600_and_h900_bestv2${it.profilePath}", + favorite = false, + ) + }.orEmpty() + this@DiscoverSeriesViewModel.people.setValueOnMain(people) + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + + fun request(id: Int) { + viewModelScope.launchIO { + val request = + seerrService.api.requestApi.requestPost( + RequestPostRequest( + is4k = false, + mediaId = id, + mediaType = RequestPostRequest.MediaType.TV, + seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons + ), + ) + fetchAndSetItem().await() + } + } + + fun cancelRequest(id: Int) { + viewModelScope.launchIO { + tvSeries.value?.mediaInfo?.requests?.firstOrNull()?.let { + // TODO handle multiple requests? Or just delete self's request? + seerrService.api.requestApi.requestRequestIdDelete(it.id.toString()) + fetchAndSetItem().await() + } + } + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index ed0d91f2..4cc50eec 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.model.SeerrItemType @@ -22,6 +23,7 @@ import com.github.damontecres.wholphin.ui.detail.FavoritesPage import com.github.damontecres.wholphin.ui.detail.PersonPage import com.github.damontecres.wholphin.ui.detail.PlaylistDetails import com.github.damontecres.wholphin.ui.detail.discover.DiscoverMovieDetails +import com.github.damontecres.wholphin.ui.detail.discover.DiscoverSeriesDetails import com.github.damontecres.wholphin.ui.detail.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails @@ -268,15 +270,24 @@ fun DestinationContent( } SeerrItemType.TV -> { - TODO() + DiscoverSeriesDetails( + preferences = preferences, + destination = destination, + ) } SeerrItemType.PERSON -> { - TODO() + Text( + text = "Person not supported yet", + color = MaterialTheme.colorScheme.onSurface, + ) } SeerrItemType.UNKNOWN -> { - TODO() + Text( + text = "Unknown discover type", + color = MaterialTheme.colorScheme.onSurface, + ) } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt index b59b4417..6b70c5e5 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/seerr/SeerrDiscoverPage.kt @@ -3,6 +3,7 @@ package com.github.damontecres.wholphin.ui.seerr import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -135,7 +136,8 @@ fun SeerrDiscoverPage( modifier = Modifier .verticalScroll(scrollState) - .padding(horizontal = 16.dp), + .fillMaxSize() + .padding(start = 16.dp, end = 16.dp, bottom = 40.dp), ) { ItemRow( title = stringResource(R.string.movies),