From 298016b131d74d17bbe1efe256956b0f3a5f7770 Mon Sep 17 00:00:00 2001 From: damontecres <154766448+damontecres@users.noreply.github.com> Date: Tue, 21 Oct 2025 23:30:18 -0400 Subject: [PATCH] Show similar items on movie & tv show pages (#52) Adds a row of similar items to the Movie & TV Show details pages --- .../wholphin/ui/detail/SeriesDetails.kt | 26 +++++++++ .../wholphin/ui/detail/SeriesViewModel.kt | 17 ++++++ .../wholphin/ui/detail/movie/MovieDetails.kt | 53 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt index 9b4c92b9..0b74148f 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesDetails.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +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 @@ -100,6 +101,7 @@ fun SeriesDetails( val item by viewModel.item.observeAsState() val seasons by viewModel.seasons.observeAsState(ItemListAndMapping.empty()) val people by viewModel.people.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) var overviewDialog by remember { mutableStateOf(null) } var showWatchConfirmation by remember { mutableStateOf(false) } @@ -118,6 +120,7 @@ fun SeriesDetails( series = item, seasons = seasons, people = people, + similar = similar, played = played, modifier = modifier, onClickItem = { viewModel.navigateTo(it.destination()) }, @@ -180,6 +183,7 @@ fun SeriesDetailsContent( preferences: UserPreferences, series: BaseItem, seasons: ItemListAndMapping, + similar: List, people: List, played: Boolean, onClickItem: (BaseItem) -> Unit, @@ -302,6 +306,28 @@ fun SeriesDetailsContent( ) } } + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = onClickItem, + onLongClickItem = {}, + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + showImageOverlay = true, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } } } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt index dd30fd21..04df03b4 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/SeriesViewModel.kt @@ -8,11 +8,13 @@ import androidx.lifecycle.viewModelScope import androidx.media3.common.util.UnstableApi import com.github.damontecres.wholphin.data.ChosenStreams 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.ItemPlayback import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.preferences.ThemeSongVolume import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination @@ -47,6 +49,7 @@ import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.SortOrder import org.jellyfin.sdk.model.api.request.GetEpisodesRequest import org.jellyfin.sdk.model.api.request.GetItemsRequest +import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -57,6 +60,7 @@ class SeriesViewModel constructor( api: ApiClient, @param:ApplicationContext val context: Context, + private val serverRepository: ServerRepository, private val navigationManager: NavigationManager, private val itemPlaybackRepository: ItemPlaybackRepository, private val themeSongPlayer: ThemeSongPlayer, @@ -67,6 +71,7 @@ class SeriesViewModel val seasons = MutableLiveData(ItemListAndMapping.empty()) val episodes = MutableLiveData(EpisodeList.Loading) val people = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) fun init( prefs: UserPreferences, @@ -102,6 +107,18 @@ class SeriesViewModel people.map { Person.fromDto(it, api) } }.orEmpty() } + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser?.id, + itemId = itemId, + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api, true) } + this@SeriesViewModel.similar.setValueOnMain(similar) } } 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 8add8b02..486d98cf 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 @@ -47,6 +47,7 @@ import coil3.compose.AsyncImage import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.data.ChosenStreams 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.ItemPlayback @@ -54,7 +55,9 @@ import com.github.damontecres.wholphin.data.model.Person import com.github.damontecres.wholphin.data.model.chooseSource import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.SlimItemFields import com.github.damontecres.wholphin.ui.cards.ChapterRow +import com.github.damontecres.wholphin.ui.cards.ItemRow import com.github.damontecres.wholphin.ui.cards.PersonRow import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.components.DialogParams @@ -76,6 +79,7 @@ import com.github.damontecres.wholphin.ui.launchIO import com.github.damontecres.wholphin.ui.letNotEmpty import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.nav.NavigationManager +import com.github.damontecres.wholphin.ui.setValueOnMain import com.github.damontecres.wholphin.ui.tryRequestFocus import com.github.damontecres.wholphin.util.ExceptionHandler import com.github.damontecres.wholphin.util.LoadingState @@ -85,9 +89,11 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.sdk.api.client.ApiClient +import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.playStateApi import org.jellyfin.sdk.api.client.extensions.userLibraryApi import org.jellyfin.sdk.model.api.MediaStreamType +import org.jellyfin.sdk.model.api.request.GetSimilarItemsRequest import org.jellyfin.sdk.model.extensions.ticks import org.jellyfin.sdk.model.serializer.toUUID import java.util.UUID @@ -100,13 +106,16 @@ class MovieViewModel constructor( api: ApiClient, val navigationManager: NavigationManager, + val serverRepository: ServerRepository, val itemPlaybackRepository: ItemPlaybackRepository, ) : LoadingItemViewModel(api) { private lateinit var itemId: UUID + + val trailers = MutableLiveData>(listOf()) val people = MutableLiveData>(listOf()) val chapters = MutableLiveData>(listOf()) + val similar = MutableLiveData>(listOf()) val chosenStreams = MutableLiveData(null) - val trailers = MutableLiveData>(listOf()) override fun init( itemId: UUID, @@ -156,6 +165,19 @@ class MovieViewModel }.orEmpty() chapters.value = Chapter.fromDto(item.data, api) } + + val similar = + api.libraryApi + .getSimilarItems( + GetSimilarItemsRequest( + userId = serverRepository.currentUser?.id, + itemId = itemId, + fields = SlimItemFields, + limit = 25, + ), + ).content.items + .map { BaseItem.from(it, api) } + this@MovieViewModel.similar.setValueOnMain(similar) } } } @@ -226,6 +248,7 @@ fun MovieDetails( val people by viewModel.people.observeAsState(listOf()) val chapters by viewModel.chapters.observeAsState(listOf()) val trailers by viewModel.trailers.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) val loading by viewModel.loading.observeAsState(LoadingState.Loading) val chosenStreams by viewModel.chosenStreams.observeAsState(null) @@ -247,6 +270,10 @@ fun MovieDetails( people = people, chapters = chapters, trailers = trailers, + similar = similar, + onClickItem = { + viewModel.navigationManager.navigateTo(it.destination()) + }, playOnClick = { viewModel.navigationManager.navigateTo( Destination.Playback( @@ -375,11 +402,13 @@ fun MovieDetailsContent( people: List, chapters: List, trailers: List, + similar: List, playOnClick: (Duration) -> Unit, trailerOnClick: (Trailer) -> Unit, overviewOnClick: () -> Unit, watchOnClick: () -> Unit, moreOnClick: () -> Unit, + onClickItem: (BaseItem) -> Unit, modifier: Modifier = Modifier, ) { val scope = rememberCoroutineScope() @@ -492,6 +521,28 @@ fun MovieDetailsContent( ) } } + if (similar.isNotEmpty()) { + item { + ItemRow( + title = stringResource(R.string.more_like_this), + items = similar, + onClickItem = onClickItem, + onLongClickItem = {}, + cardContent = { index, item, mod, onClick, onLongClick -> + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + showImageOverlay = true, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } + } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e6639fc4..d5f8dac5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -84,5 +84,6 @@ Direct play PGS subtitles Trailers Passout Protection + More like this