Show discovered items on movie & series pages

This commit is contained in:
Damontecres 2026-01-06 17:24:50 -05:00
parent 4d8d389582
commit e7060cedfd
No known key found for this signature in database
7 changed files with 135 additions and 14 deletions

View file

@ -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,

View file

@ -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<DiscoverItem> =
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()
}
}

View file

@ -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<ItemDetailsDialogInfo?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(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<Trailer>,
extras: List<ExtrasItem>,
similar: List<BaseItem>,
discovered: List<DiscoverItem>,
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],
)
}
}
}
}
}

View file

@ -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<List<ExtrasItem>>(listOf())
val similar = MutableLiveData<List<BaseItem>>()
val chosenStreams = MutableLiveData<ChosenStreams?>(null)
val discovered = MutableStateFlow<List<DiscoverItem>>(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)

View file

@ -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<ItemDetailsDialogInfo?>(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<Trailer>,
extras: List<ExtrasItem>,
people: List<Person>,
discovered: List<DiscoverItem>,
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<DialogParams?>(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],
)
}
}
}
}
}

View file

@ -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<List<BaseItem>>()
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(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 }
}
}
}
}

View file

@ -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) },