From e7060cedfdaab65714000bd3a67bbf53b4022c20 Mon Sep 17 00:00:00 2001 From: Damontecres Date: Tue, 6 Jan 2026 17:24:50 -0500 Subject: [PATCH] Show discovered items on movie & series pages --- .../wholphin/data/model/DiscoverItem.kt | 20 +++++++++++ .../wholphin/services/SeerrService.kt | 32 ++++++++++++++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 33 ++++++++++++++++++- .../ui/detail/movie/MovieViewModel.kt | 10 ++++++ .../ui/detail/series/SeriesDetails.kt | 33 ++++++++++++++++++- .../ui/detail/series/SeriesViewModel.kt | 8 +++++ .../wholphin/ui/discover/SeerrDiscoverPage.kt | 13 +------- 7 files changed, 135 insertions(+), 14 deletions(-) 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 e5e97a50..78d7b85a 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 @@ -9,6 +9,7 @@ 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 com.github.damontecres.wholphin.ui.nav.Destination import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.UseSerializers @@ -78,6 +79,25 @@ data class DiscoverItem( val backDropUrl: String? get() = backdropPath?.let { "https://image.tmdb.org/t/p/w1920_and_h800_multi_faces$it" } val posterUrl: String? get() = posterPath?.let { "https://image.tmdb.org/t/p/w500$it" } + val destination: Destination + get() { + val jfType = + when (type) { + SeerrItemType.MOVIE -> BaseItemKind.MOVIE + SeerrItemType.TV -> BaseItemKind.SERIES + SeerrItemType.PERSON -> BaseItemKind.PERSON + SeerrItemType.UNKNOWN -> null + } + return if (jellyfinItemId != null && jfType != null) { + Destination.MediaItem( + itemId = jellyfinItemId, + type = jfType, + ) + } else { + Destination.DiscoveredItem(this) + } + } + constructor(movie: MovieResult) : this( id = movie.id, type = SeerrItemType.MOVIE, diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index 8cb62ce4..78a37893 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -2,7 +2,9 @@ package com.github.damontecres.wholphin.services import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner +import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.DiscoverItem +import org.jellyfin.sdk.model.api.BaseItemKind import javax.inject.Inject import javax.inject.Singleton @@ -40,4 +42,34 @@ class SeerrService .results ?.map(::DiscoverItem) .orEmpty() + + suspend fun similar(item: BaseItem): List = + if (active) { + item.data.providerIds + ?.get("Tmdb") + ?.toIntOrNull() + ?.let { + when (item.type) { + BaseItemKind.MOVIE -> { + api.moviesApi + .movieMovieIdSimilarGet(movieId = it) + .results + ?.map(::DiscoverItem) + } + + BaseItemKind.SERIES, BaseItemKind.SEASON, BaseItemKind.EPISODE -> { + api.tvApi + .tvTvIdSimilarGet(tvId = it) + .results + ?.map(::DiscoverItem) + } + + else -> { + null + } + } + }.orEmpty() + } else { + emptyList() + } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt index 7f89f298..16938ef2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieDetails.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.bringIntoViewRequester import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -31,6 +32,7 @@ import com.github.damontecres.wholphin.data.ChosenStreams import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +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 @@ -61,8 +63,11 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItems import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberInt +import com.github.damontecres.wholphin.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -98,6 +103,7 @@ fun MovieDetails( val similar by viewModel.similar.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val chosenStreams by viewModel.chosenStreams.observeAsState(null) + val discovered by viewModel.discovered.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var moreDialog by remember { mutableStateOf(null) } @@ -291,6 +297,10 @@ fun MovieDetails( onClickExtra = { index, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, modifier = modifier, ) } @@ -351,6 +361,7 @@ private const val TRAILER_ROW = PEOPLE_ROW + 1 private const val CHAPTER_ROW = TRAILER_ROW + 1 private const val EXTRAS_ROW = CHAPTER_ROW + 1 private const val SIMILAR_ROW = EXTRAS_ROW + 1 +private const val DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun MovieDetailsContent( @@ -362,6 +373,7 @@ fun MovieDetailsContent( trailers: List, extras: List, similar: List, + discovered: List, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, @@ -373,12 +385,13 @@ fun MovieDetailsContent( onLongClickPerson: (Int, Person) -> Unit, onLongClickSimilar: (Int, BaseItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current val scope = rememberCoroutineScope() var position by rememberInt(0) - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val dto = movie.data val resumePosition = dto.userData?.playbackPositionTicks?.ticks ?: Duration.ZERO @@ -526,6 +539,24 @@ fun MovieDetailsContent( ) } } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt index e7bd7168..ca571934 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/movie/MovieViewModel.kt @@ -10,6 +10,7 @@ import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.Chapter +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -40,6 +42,8 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient @@ -54,6 +58,7 @@ class MovieViewModel @AssistedInject constructor( private val api: ApiClient, + private val seerrService: SeerrService, @param:ApplicationContext private val context: Context, private val navigationManager: NavigationManager, val serverRepository: ServerRepository, @@ -81,6 +86,7 @@ class MovieViewModel val extras = MutableLiveData>(listOf()) val similar = MutableLiveData>() val chosenStreams = MutableLiveData(null) + val discovered = MutableStateFlow>(listOf()) init { init() @@ -140,6 +146,10 @@ class MovieViewModel val extras = extrasService.getExtras(item.id) this@MovieViewModel.extras.setValueOnMain(extras) } + viewModelScope.launchIO { + val results = seerrService.similar(item) + discovered.update { results } + } withContext(Dispatchers.Main) { chapters.value = Chapter.fromDto(item.data, api) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt index 7546ca56..c63ead92 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesDetails.kt @@ -16,6 +16,7 @@ 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.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf @@ -40,6 +41,7 @@ import androidx.tv.material3.Text 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.preferences.UserPreferences @@ -71,9 +73,12 @@ import com.github.damontecres.wholphin.ui.detail.PlaylistDialog import com.github.damontecres.wholphin.ui.detail.PlaylistLoadingState import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.discover.DiscoverRow +import com.github.damontecres.wholphin.ui.discover.DiscoverRowData 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.util.DataLoadingState import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState import kotlinx.coroutines.launch @@ -104,6 +109,7 @@ fun SeriesDetails( val extras by viewModel.extras.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf()) + val discovered by viewModel.discovered.collectAsState() var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } @@ -208,6 +214,10 @@ fun SeriesDetails( onClickExtra = { _, extra -> viewModel.navigateTo(extra.destination) }, + discovered = discovered, + onClickDiscover = { index, item -> + viewModel.navigateTo(item.destination) + }, moreActions = MoreDialogActions( navigateTo = { viewModel.navigateTo(it) }, @@ -281,6 +291,7 @@ 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 DISCOVER_ROW = SIMILAR_ROW + 1 @Composable fun SeriesDetailsContent( @@ -291,6 +302,7 @@ fun SeriesDetailsContent( trailers: List, extras: List, people: List, + discovered: List, played: Boolean, favorite: Boolean, onClickItem: (Int, BaseItem) -> Unit, @@ -303,6 +315,7 @@ fun SeriesDetailsContent( trailerOnClick: (Trailer) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit, moreActions: MoreDialogActions, + onClickDiscover: (Int, DiscoverItem) -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current @@ -310,7 +323,7 @@ fun SeriesDetailsContent( val bringIntoViewRequester = remember { BringIntoViewRequester() } var position by rememberInt() - val focusRequesters = remember { List(SIMILAR_ROW + 1) { FocusRequester() } } + val focusRequesters = remember { List(DISCOVER_ROW + 1) { FocusRequester() } } val playFocusRequester = remember { FocusRequester() } RequestOrRestoreFocus(focusRequesters.getOrNull(position)) var moreDialog by remember { mutableStateOf(null) } @@ -546,6 +559,24 @@ fun SeriesDetailsContent( ) } } + if (discovered.isNotEmpty()) { + item { + DiscoverRow( + row = + DiscoverRowData( + stringResource(R.string.discover), + DataLoadingState.Success(discovered), + ), + onClickItem = { index: Int, item: DiscoverItem -> + position = DISCOVER_ROW + onClickDiscover.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + onCardFocus = {}, + focusRequester = focusRequesters[DISCOVER_ROW], + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt index 16c6cb37..aef74de1 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/series/SeriesViewModel.kt @@ -9,6 +9,7 @@ import com.github.damontecres.wholphin.data.ExtrasItem import com.github.damontecres.wholphin.data.ItemPlaybackRepository import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.Trailer @@ -18,6 +19,7 @@ import com.github.damontecres.wholphin.services.ExtrasService import com.github.damontecres.wholphin.services.FavoriteWatchManager import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.PeopleFavorites +import com.github.damontecres.wholphin.services.SeerrService import com.github.damontecres.wholphin.services.StreamChoiceService import com.github.damontecres.wholphin.services.ThemeSongPlayer import com.github.damontecres.wholphin.services.TrailerService @@ -84,6 +86,7 @@ class SeriesViewModel val streamChoiceService: StreamChoiceService, private val userPreferencesService: UserPreferencesService, private val backdropService: BackdropService, + private val seerrService: SeerrService, @Assisted val seriesId: UUID, @Assisted val seasonEpisodeIds: SeasonEpisodeIds?, @Assisted val seriesPageType: SeriesPageType, @@ -107,6 +110,7 @@ class SeriesViewModel val similar = MutableLiveData>() val peopleInEpisode = MutableLiveData(PeopleInItem()) + val discovered = MutableStateFlow>(listOf()) val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) @@ -210,6 +214,10 @@ class SeriesViewModel this@SeriesViewModel.similar.setValueOnMain(similar) } } + viewModelScope.launchIO { + val results = seerrService.similar(item) + discovered.update { results } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt index acd5f380..38acd988 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/discover/SeerrDiscoverPage.kt @@ -42,7 +42,6 @@ import com.github.damontecres.wholphin.ui.components.ErrorMessage import com.github.damontecres.wholphin.ui.data.RowColumn import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.main.HomePageHeader -import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.rememberPosition import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.DataLoadingState @@ -51,7 +50,6 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import org.jellyfin.sdk.api.client.ApiClient -import org.jellyfin.sdk.model.api.BaseItemKind import javax.inject.Inject @HiltViewModel @@ -178,16 +176,7 @@ fun SeerrDiscoverPage( row = row, onClickItem = { index, item -> position = RowColumn(rowIndex, index) - if (item.jellyfinItemId != null) { - viewModel.navigationManager.navigateTo( - Destination.MediaItem( - itemId = item.jellyfinItemId, - type = BaseItemKind.MOVIE, - ), - ) - } else { - viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) - } + viewModel.navigationManager.navigateTo(item.destination) }, onLongClickItem = { index, item -> }, onCardFocus = { index -> position = RowColumn(rowIndex, index) },