From 69fb26a070d96d1728c9010986b28a4a60675d4d Mon Sep 17 00:00:00 2001 From: Damontecres Date: Sun, 7 Dec 2025 22:08:04 -0500 Subject: [PATCH] PoC movie details --- .../wholphin/data/model/DiscoverItem.kt | 71 +++ .../wholphin/services/SeerrService.kt | 13 +- .../wholphin/ui/cards/DiscoverItemCard.kt | 33 ++ .../detail/discover/DiscoverMovieDetails.kt | 456 ++++++++++++++++++ .../discover/DiscoverMovieDetailsHeader.kt | 140 ++++++ .../detail/discover/DiscoverMovieViewModel.kt | 144 ++++++ .../discover/ExpandableDiscoverButtons.kt | 65 +++ .../wholphin/ui/nav/Destination.kt | 6 + .../wholphin/ui/nav/DestinationContent.kt | 21 + .../wholphin/ui/seerr/SeerrDiscoverPage.kt | 57 ++- app/src/main/res/values/fa_strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/seerr-api.yml | 28 +- 13 files changed, 997 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt create mode 100644 app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.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 new file mode 100644 index 00000000..0a3a2c8b --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/data/model/DiscoverItem.kt @@ -0,0 +1,71 @@ +package com.github.damontecres.wholphin.data.model + +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.TvResult +import kotlinx.serialization.Serializable + +@Serializable +enum class SeerrItemType { + MOVIE, + TV, + PERSON, +} + +@Serializable +enum class SeerrAvailability( + val status: Int, +) { + UNKNOWN(1), + PENDING(2), + PROCESSING(3), + PARTIALLY_AVAILABLE(4), + AVAILABLE(5), + DELETED(6), + ; + + companion object { + fun from(status: Int?) = entries.firstOrNull { it.status == status } + } +} + +@Serializable +data class DiscoverItem( + val id: Int, + val type: SeerrItemType, + val title: String?, + val availability: SeerrAvailability, + val releaseDate: String?, + val posterPath: String?, + val backdropPath: String?, +) { + constructor(movie: MovieResult) : this( + id = movie.id, + type = SeerrItemType.MOVIE, + title = movie.title, + availability = SeerrAvailability.from(movie.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = movie.releaseDate, + posterPath = movie.posterPath, + backdropPath = movie.backdropPath, + ) + + constructor(tv: TvResult) : this( + id = tv.id!!, + type = SeerrItemType.MOVIE, + title = tv.name, + availability = SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN, + releaseDate = tv.firstAirDate, + posterPath = tv.posterPath, + backdropPath = tv.backdropPath, + ) +} + +data class DiscoverRating( + val criticRating: Int?, + val audienceRating: Float?, +) { + constructor(rating: MovieMovieIdRatingsGet200Response) : this( + criticRating = rating.criticsScore, + audienceRating = rating.audienceScore?.div(10f), + ) +} 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 652f9ab3..8cb62ce4 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,6 +2,7 @@ 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.DiscoverItem import javax.inject.Inject import javax.inject.Singleton @@ -13,14 +14,10 @@ class SeerrService constructor( private val seerApi: SeerrApi, ) { - private val api: SeerrApiClient get() = seerApi.api + val api: SeerrApiClient get() = seerApi.api val active: Boolean get() = seerApi.active - suspend fun init() { - api.searchApi.discoverTvGet(1) - } - suspend fun search( query: String, page: Int = 1, @@ -30,15 +27,17 @@ class SeerrService .results .orEmpty() - suspend fun discoverTv(page: Int = 1) = + suspend fun discoverTv(page: Int = 1): List = api.searchApi .discoverTvGet(page = page) .results + ?.map(::DiscoverItem) .orEmpty() - suspend fun discoverMovies(page: Int = 1) = + suspend fun discoverMovies(page: Int = 1): List = api.searchApi .discoverMoviesGet(page = page) .results + ?.map(::DiscoverItem) .orEmpty() } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt new file mode 100644 index 00000000..ac77d7a8 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/cards/DiscoverItemCard.kt @@ -0,0 +1,33 @@ +package com.github.damontecres.wholphin.ui.cards + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import com.github.damontecres.wholphin.data.model.DiscoverItem +import com.github.damontecres.wholphin.ui.Cards + +@Composable +@NonRestartableComposable +fun DiscoverItemCard( + item: DiscoverItem?, + onClick: () -> Unit, + onLongClick: () -> Unit, + modifier: Modifier = Modifier, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) = SeasonCard( + title = item?.title, + subtitle = item?.releaseDate, + name = item?.title, + imageUrl = item?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }, // TODO + isFavorite = false, + isPlayed = false, + unplayedItemCount = -1, + playedPercentage = -1.0, + onClick = onClick, + onLongClick = onLongClick, + imageHeight = Cards.height2x3, + modifier = modifier, + interactionSource = interactionSource, +) 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 new file mode 100644 index 00000000..15dc236a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetails.kt @@ -0,0 +1,456 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +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.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.foundation.relocation.bringIntoViewRequester +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.focus.focusRestorer +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.ExtrasItem +import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.data.model.LocalTrailer +import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.RemoteTrailer +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 +import com.github.damontecres.wholphin.ui.cards.ExtrasRow +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.DetailsBackdropImage +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 +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForHome +import com.github.damontecres.wholphin.ui.detail.buildMoreDialogItemsForPerson +import com.github.damontecres.wholphin.ui.nav.Destination +import com.github.damontecres.wholphin.ui.rememberInt +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 + +@Composable +fun DiscoverMovieDetails( + preferences: UserPreferences, + destination: Destination.DiscoveredItem, + modifier: Modifier = Modifier, + viewModel: DiscoverMovieViewModel = + hiltViewModel( + creationCallback = { it.create(destination.item) }, + ), + playlistViewModel: AddPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + LifecycleResumeEffect(Unit) { + viewModel.init() + onPauseOrDispose { } + } + val item by viewModel.movie.observeAsState() + val rating by viewModel.rating.observeAsState(null) + val people by viewModel.people.observeAsState(listOf()) + val trailers by viewModel.trailers.observeAsState(listOf()) + val extras by viewModel.extras.observeAsState(listOf()) + val similar by viewModel.similar.observeAsState(listOf()) + val loading by viewModel.loading.observeAsState(LoadingState.Loading) + + var overviewDialog by remember { mutableStateOf(null) } + var moreDialog by remember { mutableStateOf(null) } + + val moreActions = + MoreDialogActions( + navigateTo = viewModel::navigateTo, + onClickWatch = { itemId, watched -> }, + onClickFavorite = { itemId, favorite -> }, + onClickAddPlaylist = { itemId -> }, + ) + + when (val state = loading) { + is LoadingState.Error -> { + ErrorMessage(state) + } + + LoadingState.Loading, + LoadingState.Pending, + -> { + LoadingPage() + } + + LoadingState.Success -> { + item?.let { movie -> + DiscoverMovieDetailsContent( + preferences = preferences, + movie = movie, + rating = rating, + people = people, + extras = extras, + trailers = trailers, + similar = similar, + requestOnClick = { + // TODO seerr + }, + onClickItem = { index, item -> + viewModel.navigateTo(item.destination()) + }, + onClickPerson = { + }, + overviewOnClick = { + overviewDialog = + ItemDetailsDialogInfo( + title = movie.title ?: context.getString(R.string.unknown), + overview = movie.overview, + genres = movie.genres?.mapNotNull { it.name }.orEmpty(), + files = listOf(), + ) + }, + moreOnClick = { + moreDialog = + DialogParams( + fromLongClick = false, + title = movie.title + " (${movie.releaseDate ?: ""})", + items = listOf(), + ) + }, + onLongClickPerson = { index, person -> + val items = + buildMoreDialogItemsForPerson( + context = context, + person = person, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = person.name ?: "", + items = items, + ) + }, + onLongClickSimilar = { index, similar -> + val items = + buildMoreDialogItemsForHome( + context = context, + item = similar, + seriesId = null, + playbackPosition = similar.playbackPosition, + watched = similar.played, + favorite = similar.favorite, + actions = moreActions, + ) + moreDialog = + DialogParams( + fromLongClick = true, + title = similar.title ?: "", + items = items, + ) + }, + trailerOnClick = { + TrailerService.onClick(context, it, viewModel::navigateTo) + }, + onClickExtra = { index, extra -> + viewModel.navigateTo(extra.destination) + }, + modifier = modifier, + ) + } + } + } + overviewDialog?.let { info -> + ItemDetailsDialog( + info = info, + showFilePath = false, + onDismissRequest = { overviewDialog = null }, + ) + } + moreDialog?.let { params -> + DialogPopup( + showDialog = true, + title = params.title, + dialogItems = params.items, + onDismissRequest = { moreDialog = null }, + dismissOnClick = true, + waitToLoad = params.fromLongClick, + ) + } +} + +private const val HEADER_ROW = 0 +private const val PEOPLE_ROW = HEADER_ROW + 1 +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 + +@Composable +fun DiscoverMovieDetailsContent( + preferences: UserPreferences, + movie: MovieDetails, + rating: DiscoverRating?, + people: List, + trailers: List, + extras: List, + similar: List, + requestOnClick: () -> Unit, + trailerOnClick: (Trailer) -> Unit, + overviewOnClick: () -> Unit, + moreOnClick: () -> Unit, + onClickItem: (Int, BaseItem) -> Unit, + onClickPerson: (Person) -> Unit, + onLongClickPerson: (Int, Person) -> Unit, + onLongClickSimilar: (Int, BaseItem) -> Unit, + onClickExtra: (Int, ExtrasItem) -> 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 bringIntoViewRequester = remember { BringIntoViewRequester() } + LaunchedEffect(Unit) { + focusRequesters.getOrNull(position)?.tryRequestFocus() + } + Box(modifier = modifier) { + movie.backdropPath?.let { + DetailsBackdropImage("https://image.tmdb.org/t/p/w1920_and_h800_multi_faces/$it") + } + LazyColumn( + verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 32.dp, vertical = 8.dp), + modifier = Modifier.fillMaxSize(), + ) { + item { + Column( + verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = + Modifier + .fillMaxWidth() + .bringIntoViewRequester(bringIntoViewRequester), + ) { + DiscoverMovieDetailsHeader( + preferences = preferences, + movie = movie, + rating = rating, + bringIntoViewRequester = bringIntoViewRequester, + overviewOnClick = overviewOnClick, + modifier = + Modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 16.dp), + ) + ExpandableDiscoverButtons( + requestOnClick = requestOnClick, + moreOnClick = moreOnClick, + buttonOnFocusChanged = { + if (it.isFocused) { + position = HEADER_ROW + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesters[HEADER_ROW]), + ) + } + } + if (people.isNotEmpty()) { + item { + PersonRow( + people = people, + onClick = { + position = PEOPLE_ROW + onClickPerson.invoke(it) + }, + onLongClick = { index, person -> + position = PEOPLE_ROW + onLongClickPerson.invoke(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 (extras.isNotEmpty()) { + item { + ExtrasRow( + extras = extras, + onClickItem = { index, item -> + position = EXTRAS_ROW + onClickExtra.invoke(index, item) + }, + onLongClickItem = { _, _ -> }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[EXTRAS_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 -> + SeasonCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + showImageOverlay = true, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequesters[SIMILAR_ROW]), + ) + } + } + } + } +} + +@Composable +fun TrailerRow( + trailers: List, + onClickTrailer: (Trailer) -> Unit, + modifier: Modifier = Modifier, +) { + val state = rememberLazyListState() + val firstFocus = remember { FocusRequester() } + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + Text( + text = stringResource(R.string.trailers), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + LazyRow( + state = state, + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + modifier = + Modifier + .fillMaxWidth() + .focusRestorer(firstFocus), + ) { + itemsIndexed(trailers) { index, item -> + val cardModifier = + if (index == 0) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + } + when (item) { + is LocalTrailer -> { + SeasonCard( + item = item.baseItem, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + showImageOverlay = false, + modifier = cardModifier, + ) + } + + is RemoteTrailer -> { + val subtitle = + when (item.url.toUri().host) { + "youtube.com", "www.youtube.com" -> "YouTube" + else -> null + } + SeasonCard( + title = item.name, + subtitle = subtitle, + name = item.name, + imageUrl = null, + isFavorite = false, + isPlayed = false, + unplayedItemCount = 0, + playedPercentage = 0.0, + onClick = { onClickTrailer.invoke(item) }, + onLongClick = {}, + modifier = cardModifier, + showImageOverlay = false, + imageHeight = Cards.height2x3, + imageWidth = Dp.Unspecified, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt new file mode 100644 index 00000000..6d24b7f7 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieDetailsHeader.kt @@ -0,0 +1,140 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.relocation.BringIntoViewRequester +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.api.seerr.model.MovieDetails +import com.github.damontecres.wholphin.data.model.DiscoverRating +import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.ui.components.DotSeparatedRow +import com.github.damontecres.wholphin.ui.components.GenreText +import com.github.damontecres.wholphin.ui.components.OverviewText +import com.github.damontecres.wholphin.ui.letNotEmpty +import com.github.damontecres.wholphin.ui.roundMinutes +import com.github.damontecres.wholphin.util.ExceptionHandler +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.minutes + +@Composable +fun DiscoverMovieDetailsHeader( + preferences: UserPreferences, + movie: MovieDetails, + rating: DiscoverRating?, + bringIntoViewRequester: BringIntoViewRequester, + overviewOnClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier, + ) { + // Title + Text( + text = movie.title ?: "", + color = MaterialTheme.colorScheme.onSurface, + 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(movie) { + buildList { + movie.releaseDate?.let(::add) + movie.runtime + ?.toDouble() + ?.minutes + ?.roundMinutes + ?.toString() + ?.let(::add) + movie.releases + ?.results + ?.firstOrNull() + ?.releaseDates + ?.firstOrNull() + ?.certification + ?.let(::add) + } + } + + DotSeparatedRow( + texts = details, + communityRating = rating?.audienceRating, + criticRating = rating?.criticRating?.toFloat(), + textStyle = MaterialTheme.typography.titleSmall, + modifier = Modifier, + ) + movie.genres?.mapNotNull { it.name }?.letNotEmpty { + GenreText(it, Modifier.padding(bottom = padding)) + } + + movie.tagline?.let { tagline -> + Text( + text = tagline, + style = MaterialTheme.typography.bodyLarge, + fontStyle = FontStyle.Italic, + modifier = Modifier, + ) + } + + // Description + movie.overview?.let { overview -> + OverviewText( + overview = overview, + maxLines = 3, + onClick = overviewOnClick, + textBoxHeight = Dp.Unspecified, + modifier = + Modifier.onFocusChanged { + if (it.isFocused) { + scope.launch(ExceptionHandler()) { + bringIntoViewRequester.bringIntoView() + } + } + }, + ) + } + + val directorName = + remember(movie.credits?.crew) { + movie.credits + ?.crew + ?.filter { it.job == "Directing" } + ?.joinToString(", ") { it.name!! } + } + + directorName + ?.let { + Text( + text = stringResource(R.string.directed_by, it), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt new file mode 100644 index 00000000..38742909 --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverMovieViewModel.kt @@ -0,0 +1,144 @@ +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.MovieDetails +import com.github.damontecres.wholphin.data.ChosenStreams +import com.github.damontecres.wholphin.data.ExtrasItem +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.DiscoverRating +import com.github.damontecres.wholphin.data.model.Person +import com.github.damontecres.wholphin.data.model.Trailer +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 = DiscoverMovieViewModel.Factory::class) +class DiscoverMovieViewModel + @AssistedInject + constructor( + private val api: ApiClient, + @param:ApplicationContext private val context: Context, + private val navigationManager: NavigationManager, + val serverRepository: ServerRepository, + val seerrService: SeerrService, + @Assisted val item: DiscoverItem, + ) : ViewModel() { + @AssistedFactory + interface Factory { + fun create(item: DiscoverItem): DiscoverMovieViewModel + } + + val loading = MutableLiveData(LoadingState.Pending) + val movie = MutableLiveData(null) + val rating = MutableLiveData(null) + + val trailers = MutableLiveData>(listOf()) + val people = MutableLiveData>(listOf()) + val chapters = MutableLiveData>(listOf()) + val extras = MutableLiveData>(listOf()) + val similar = MutableLiveData>() + val chosenStreams = MutableLiveData(null) + + init { + init() + } + + private fun fetchAndSetItem(): Deferred = + viewModelScope.async( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = seerrService.api.moviesApi.movieMovieIdGet(movieId = item.id) + this@DiscoverMovieViewModel.movie.setValueOnMain(movie) + movie + } + + fun init(): Job = + viewModelScope.launch( + Dispatchers.IO + + LoadingExceptionHandler( + loading, + "Error fetching movie", + ), + ) { + val movie = fetchAndSetItem().await() + withContext(Dispatchers.Main) { + loading.value = LoadingState.Success + } + viewModelScope.launchIO { + val result = seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id) + rating.setValueOnMain(DiscoverRating(result)) + } + val people = + movie.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() + + movie.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@DiscoverMovieViewModel.people.setValueOnMain(people) + if (!similar.isInitialized) { +// val similar = +// api.libraryApi +// .getSimilarItems( +// GetSimilarItemsRequest( +// userId = serverRepository.currentUser.value?.id, +// itemId = itemId, +// fields = SlimItemFields, +// limit = 25, +// ), +// ).content.items +// .map { BaseItem.Companion.from(it, api) } +// this@DiscoverMovieViewModel.similar.setValueOnMain(similar) + } + } + + fun navigateTo(destination: Destination) { + navigationManager.navigateTo(destination) + } + } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt new file mode 100644 index 00000000..d851157a --- /dev/null +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/ExpandableDiscoverButtons.kt @@ -0,0 +1,65 @@ +package com.github.damontecres.wholphin.ui.detail.discover + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.FocusState +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.unit.dp +import com.github.damontecres.wholphin.R +import com.github.damontecres.wholphin.ui.components.ExpandableFaButton +import com.github.damontecres.wholphin.ui.components.ExpandablePlayButton +import kotlin.time.Duration + +@Composable +fun ExpandableDiscoverButtons( + requestOnClick: () -> Unit, + moreOnClick: () -> Unit, + buttonOnFocusChanged: (FocusState) -> Unit, + modifier: Modifier = Modifier, +) { + val firstFocus = remember { FocusRequester() } + LazyRow( + horizontalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(8.dp), + modifier = + modifier + .focusGroup() + .focusRestorer(firstFocus), + ) { + item("request") { + ExpandableFaButton( + title = R.string.request, + iconStringRes = R.string.fa_download, + onClick = requestOnClick, + modifier = + Modifier + .focusRequester(firstFocus) + .onFocusChanged(buttonOnFocusChanged) + .animateItem(), + ) + } + + // More button + item("more") { + ExpandablePlayButton( + R.string.more, + Duration.ZERO, + Icons.Default.MoreVert, + { moreOnClick.invoke() }, + Modifier + .onFocusChanged(buttonOnFocusChanged) + .animateItem(), + ) + } + } +} diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 3c616cb3..1cec6762 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -5,6 +5,7 @@ package com.github.damontecres.wholphin.ui.nav import androidx.annotation.StringRes import androidx.navigation3.runtime.NavKey import com.github.damontecres.wholphin.data.model.BaseItem +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.GetItemsFilter import com.github.damontecres.wholphin.data.model.ItemPlayback import com.github.damontecres.wholphin.data.model.JellyfinServer @@ -120,6 +121,11 @@ sealed class Destination( @Serializable data object Discover : Destination(false) + @Serializable + data class DiscoveredItem( + val item: DiscoverItem, + ) : Destination(false) + @Serializable data object UpdateApp : Destination(true) 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 feaf7001..d2e748e1 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 @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.tv.material3.Text import com.github.damontecres.wholphin.data.filter.DefaultForGenresFilterOptions +import com.github.damontecres.wholphin.data.model.SeerrItemType import com.github.damontecres.wholphin.preferences.UserPreferences import com.github.damontecres.wholphin.ui.components.ItemGrid import com.github.damontecres.wholphin.ui.components.LicenseInfo @@ -19,6 +20,7 @@ import com.github.damontecres.wholphin.ui.detail.DebugPage 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.episode.EpisodeDetails import com.github.damontecres.wholphin.ui.detail.movie.MovieDetails import com.github.damontecres.wholphin.ui.detail.series.SeriesDetails @@ -249,6 +251,25 @@ fun DestinationContent( modifier = modifier, ) } + + is Destination.DiscoveredItem -> { + when (destination.item.type) { + SeerrItemType.MOVIE -> { + DiscoverMovieDetails( + preferences = preferences, + destination = destination, + ) + } + + SeerrItemType.TV -> { + TODO() + } + + SeerrItemType.PERSON -> { + TODO() + } + } + } } } 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 6c65a656..ee50fa8e 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,8 @@ package com.github.damontecres.wholphin.ui.seerr import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -12,13 +14,14 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.github.damontecres.wholphin.api.seerr.model.MovieResult +import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.preferences.UserPreferences +import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.SeerrService -import com.github.damontecres.wholphin.ui.Cards +import com.github.damontecres.wholphin.ui.cards.DiscoverItemCard import com.github.damontecres.wholphin.ui.cards.ItemRow -import com.github.damontecres.wholphin.ui.cards.SeasonCard import com.github.damontecres.wholphin.ui.launchIO +import com.github.damontecres.wholphin.ui.nav.Destination import com.github.damontecres.wholphin.ui.setValueOnMain import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -27,15 +30,21 @@ import javax.inject.Inject class SeerrDiscoverViewModel @Inject constructor( - val seerrService: SeerrService, + private val seerrService: SeerrService, + val navigationManager: NavigationManager, ) : ViewModel() { - val discoverMovies = MutableLiveData>(listOf()) + val discoverMovies = MutableLiveData>(listOf()) + val discoverTv = MutableLiveData>(listOf()) init { viewModelScope.launchIO { val movies = seerrService.discoverMovies() discoverMovies.setValueOnMain(movies) } + viewModelScope.launchIO { + val tv = seerrService.discoverTv() + discoverTv.setValueOnMain(tv) + } } } @@ -46,30 +55,44 @@ fun SeerrDiscoverPage( viewModel: SeerrDiscoverViewModel = hiltViewModel(), ) { val movies by viewModel.discoverMovies.observeAsState(listOf()) + val tv by viewModel.discoverTv.observeAsState(listOf()) + + val scrollState = rememberScrollState() Column( verticalArrangement = Arrangement.spacedBy(16.dp), modifier = modifier + .verticalScroll(scrollState) .padding(16.dp), ) { ItemRow( title = "Movies", items = movies, - onClickItem = { index, item -> }, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + }, onLongClickItem = { index, item -> }, - cardContent = { index: Int, item: MovieResult?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> - SeasonCard( - title = item?.title, - subtitle = null, - name = item?.title, - imageUrl = item?.posterPath?.let { "https://image.tmdb.org/t/p/w500$it" }, // TODO - isFavorite = false, - isPlayed = false, - unplayedItemCount = -1, - playedPercentage = -1.0, + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, + onClick = onClick, + onLongClick = onLongClick, + modifier = mod, + ) + }, + ) + ItemRow( + title = "TV", + items = tv, + onClickItem = { index, item -> + viewModel.navigationManager.navigateTo(Destination.DiscoveredItem(item)) + }, + onLongClickItem = { index, item -> }, + cardContent = { index: Int, item: DiscoverItem?, mod: Modifier, onClick: () -> Unit, onLongClick: () -> Unit -> + DiscoverItemCard( + item = item, onClick = onClick, onLongClick = onLongClick, - imageHeight = Cards.height2x3, modifier = mod, ) }, diff --git a/app/src/main/res/values/fa_strings.xml b/app/src/main/res/values/fa_strings.xml index e027f30c..e7a2b32f 100644 --- a/app/src/main/res/values/fa_strings.xml +++ b/app/src/main/res/values/fa_strings.xml @@ -43,4 +43,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b11dbb45..0b72c12a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,7 @@ Use username/password Login Discover + Request Disabled diff --git a/app/src/main/seerr-api.yml b/app/src/main/seerr-api.yml index 57a495d7..32df96ea 100644 --- a/app/src/main/seerr-api.yml +++ b/app/src/main/seerr-api.yml @@ -1993,21 +1993,19 @@ components: webpush: type: integer WatchProviders: - type: array - items: - type: object - properties: - iso_3166_1: - type: string - link: - type: string - buy: - type: array - items: - $ref: '#/components/schemas/WatchProviderDetails' - flatrate: - items: - $ref: '#/components/schemas/WatchProviderDetails' + type: object + properties: + iso_3166_1: + type: string + link: + type: string + buy: + type: array + items: + $ref: '#/components/schemas/WatchProviderDetails' + flatrate: + items: + $ref: '#/components/schemas/WatchProviderDetails' WatchProviderDetails: type: object properties: