From 2ed5dcafe4bb3b1d96fffdb72479e0b325887eef Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:47:50 +0530 Subject: [PATCH] feat: Introduce TMDB entity browsing screens --- .../nuvio/tv/core/tmdb/TmdbMetadataService.kt | 319 ++++++++++++++- .../com/nuvio/tv/data/remote/api/TmdbApi.kt | 82 ++++ .../java/com/nuvio/tv/domain/model/Meta.kt | 3 +- .../nuvio/tv/ui/navigation/NuvioNavHost.kt | 31 ++ .../java/com/nuvio/tv/ui/navigation/Screen.kt | 16 + .../ui/screens/detail/CompanyLogosSection.kt | 47 ++- .../tv/ui/screens/detail/MetaDetailsScreen.kt | 76 +++- .../ui/screens/tmdb/TmdbEntityBrowseScreen.kt | 381 ++++++++++++++++++ .../screens/tmdb/TmdbEntityBrowseUiState.kt | 9 + .../screens/tmdb/TmdbEntityBrowseViewModel.kt | 76 ++++ app/src/main/res/values/strings.xml | 7 + .../tv/core/tmdb/TmdbMetadataServiceTest.kt | 230 +++++++++++ 12 files changed, 1262 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseScreen.kt create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseUiState.kt create mode 100644 app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseViewModel.kt create mode 100644 app/src/test/java/com/nuvio/tv/core/tmdb/TmdbMetadataServiceTest.kt diff --git a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt index e2ae98af..926d809e 100644 --- a/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt +++ b/app/src/main/java/com/nuvio/tv/core/tmdb/TmdbMetadataService.kt @@ -3,6 +3,7 @@ package com.nuvio.tv.core.tmdb import android.util.Log import com.nuvio.tv.BuildConfig import com.nuvio.tv.data.remote.api.TmdbApi +import com.nuvio.tv.data.remote.api.TmdbDiscoverResult import com.nuvio.tv.data.remote.api.TmdbEpisode import com.nuvio.tv.data.remote.api.TmdbImage import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast @@ -19,6 +20,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.withContext +import java.time.LocalDate import java.util.concurrent.ConcurrentHashMap import java.util.Locale import javax.inject.Inject @@ -36,6 +38,9 @@ class TmdbMetadataService @Inject constructor( private val episodeCache = ConcurrentHashMap, TmdbEpisodeEnrichment>>() private val personCache = ConcurrentHashMap() private val moreLikeThisCache = ConcurrentHashMap>() + private val entityHeaderCache = ConcurrentHashMap() + private val entityRailCache = ConcurrentHashMap>() + private val entityBrowseCache = ConcurrentHashMap() suspend fun fetchEnrichment( tmdbId: String, @@ -122,7 +127,8 @@ class TmdbMetadataService @Inject constructor( val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null MetaCompany( name = name, - logo = buildImageUrl(company.logoPath, size = "w300") + logo = buildImageUrl(company.logoPath, size = "w300"), + tmdbId = company.id ) } val networks = details?.networks @@ -131,7 +137,8 @@ class TmdbMetadataService @Inject constructor( val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null MetaCompany( name = name, - logo = buildImageUrl(network.logoPath, size = "w300") + logo = buildImageUrl(network.logoPath, size = "w300"), + tmdbId = network.id ) } val poster = buildImageUrl(details?.posterPath, size = "w500") @@ -513,6 +520,268 @@ class TmdbMetadataService @Inject constructor( } } + suspend fun fetchEntityBrowse( + entityKind: TmdbEntityKind, + entityId: Int, + sourceType: String, + fallbackName: String? = null, + language: String = "en" + ): TmdbEntityBrowseData? = withContext(Dispatchers.IO) { + val normalizedLanguage = normalizeTmdbLanguage(language) + val normalizedSourceType = normalizeEntitySourceType(sourceType) + val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$normalizedLanguage" + entityBrowseCache[cacheKey]?.let { return@withContext it } + + val header = fetchEntityHeader( + entityKind = entityKind, + entityId = entityId, + fallbackName = fallbackName, + language = normalizedLanguage + ) + + val rails = buildEntityMediaOrder(entityKind, normalizedSourceType) + .flatMap { mediaType -> + TmdbEntityRailType.values().mapNotNull { railType -> + val items = fetchEntityRail( + entityKind = entityKind, + entityId = entityId, + mediaType = mediaType, + railType = railType, + language = normalizedLanguage + ) + if (items.isEmpty()) { + null + } else { + TmdbEntityRail( + mediaType = mediaType, + railType = railType, + items = items + ) + } + } + } + + if (header == null && rails.isEmpty()) return@withContext null + + val data = TmdbEntityBrowseData( + header = header ?: TmdbEntityHeader( + id = entityId, + kind = entityKind, + name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown", + logo = null, + originCountry = null, + secondaryLabel = null, + description = null + ), + rails = rails + ) + entityBrowseCache[cacheKey] = data + data + } + + private suspend fun fetchEntityHeader( + entityKind: TmdbEntityKind, + entityId: Int, + fallbackName: String?, + language: String + ): TmdbEntityHeader? { + val cacheKey = "${entityKind.routeValue}:$entityId:$language:header" + entityHeaderCache[cacheKey]?.let { return it } + + val header = try { + when (entityKind) { + TmdbEntityKind.COMPANY -> { + val body = tmdbApi.getCompanyDetails(entityId, TMDB_API_KEY).body() + if (body == null) { + null + } else { + TmdbEntityHeader( + id = body.id, + kind = entityKind, + name = body.name?.takeIf { it.isNotBlank() } + ?: fallbackName?.takeIf { it.isNotBlank() } + ?: "Unknown", + logo = buildImageUrl(body.logoPath, size = "w500"), + originCountry = body.originCountry?.takeIf { it.isNotBlank() }, + secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() }, + description = body.description?.takeIf { it.isNotBlank() } + ) + } + } + + TmdbEntityKind.NETWORK -> { + val body = tmdbApi.getNetworkDetails(entityId, TMDB_API_KEY).body() + if (body == null) { + null + } else { + TmdbEntityHeader( + id = body.id, + kind = entityKind, + name = body.name?.takeIf { it.isNotBlank() } + ?: fallbackName?.takeIf { it.isNotBlank() } + ?: "Unknown", + logo = buildImageUrl(body.logoPath, size = "w500"), + originCountry = body.originCountry?.takeIf { it.isNotBlank() }, + secondaryLabel = body.headquarters?.takeIf { it.isNotBlank() }, + description = null + ) + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to fetch ${entityKind.routeValue} header for $entityId: ${e.message}") + null + } ?: fallbackName?.takeIf { it.isNotBlank() }?.let { + TmdbEntityHeader( + id = entityId, + kind = entityKind, + name = it, + logo = null, + originCountry = null, + secondaryLabel = null, + description = null + ) + } + + if (header != null) { + entityHeaderCache[cacheKey] = header + } + return header + } + + private suspend fun fetchEntityRail( + entityKind: TmdbEntityKind, + entityId: Int, + mediaType: TmdbEntityMediaType, + railType: TmdbEntityRailType, + language: String + ): List { + if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) { + return emptyList() + } + + val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language" + entityRailCache[cacheKey]?.let { return it } + + val today = LocalDate.now().toString() + val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) TOP_RATED_VOTE_COUNT_FLOOR else null + val result = try { + val results = when (mediaType) { + TmdbEntityMediaType.MOVIE -> { + tmdbApi.discoverMovies( + apiKey = TMDB_API_KEY, + language = language, + sortBy = movieSortBy(railType), + withCompanies = entityId.toString(), + releaseDateLte = if (railType == TmdbEntityRailType.RECENT) today else null, + voteCountGte = voteCountFloor + ).body()?.results.orEmpty() + } + + TmdbEntityMediaType.TV -> { + tmdbApi.discoverTv( + apiKey = TMDB_API_KEY, + language = language, + sortBy = tvSortBy(railType), + withCompanies = if (entityKind == TmdbEntityKind.COMPANY) entityId.toString() else null, + withNetworks = if (entityKind == TmdbEntityKind.NETWORK) entityId.toString() else null, + firstAirDateLte = if (railType == TmdbEntityRailType.RECENT) today else null, + voteCountGte = voteCountFloor + ).body()?.results.orEmpty() + } + } + + results + .filter { it.id > 0 } + .mapNotNull { discoverItem -> + mapEntityDiscoverResult( + result = discoverItem, + mediaType = mediaType + ) + } + .take(ENTITY_RAIL_MAX_ITEMS) + } catch (e: Exception) { + Log.w( + TAG, + "Failed to fetch ${entityKind.routeValue} rail ${railType.value}/${mediaType.value} for $entityId: ${e.message}" + ) + emptyList() + } + + if (result.isNotEmpty()) { + entityRailCache[cacheKey] = result + } + return result + } + + private fun mapEntityDiscoverResult( + result: TmdbDiscoverResult, + mediaType: TmdbEntityMediaType + ): MetaPreview? { + val title = result.title?.takeIf { it.isNotBlank() } + ?: result.name?.takeIf { it.isNotBlank() } + ?: result.originalTitle?.takeIf { it.isNotBlank() } + ?: result.originalName?.takeIf { it.isNotBlank() } + ?: return null + + val poster = buildImageUrl(result.posterPath, size = "w500") + ?: buildImageUrl(result.backdropPath, size = "w780") + ?: return null + val background = buildImageUrl(result.backdropPath, size = "w1280") + val releaseInfo = when (mediaType) { + TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4) + TmdbEntityMediaType.TV -> result.firstAirDate?.take(4) + } + + return MetaPreview( + id = "tmdb:${result.id}", + type = if (mediaType == TmdbEntityMediaType.TV) ContentType.SERIES else ContentType.MOVIE, + name = title, + poster = poster, + posterShape = PosterShape.POSTER, + background = background, + logo = null, + description = result.overview?.takeIf { it.isNotBlank() }, + releaseInfo = releaseInfo, + imdbRating = result.voteAverage?.toFloat(), + genres = emptyList() + ) + } + + internal fun buildEntityMediaOrder( + entityKind: TmdbEntityKind, + sourceType: String + ): List { + if (entityKind == TmdbEntityKind.NETWORK) { + return listOf(TmdbEntityMediaType.TV) + } + + return when (normalizeEntitySourceType(sourceType)) { + "movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV) + else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE) + } + } + + private fun normalizeEntitySourceType(sourceType: String): String { + return when (sourceType.trim().lowercase(Locale.US)) { + "movie" -> "movie" + "tv", "series", "show" -> "tv" + else -> "tv" + } + } + + private fun movieSortBy(railType: TmdbEntityRailType): String = when (railType) { + TmdbEntityRailType.POPULAR -> "popularity.desc" + TmdbEntityRailType.TOP_RATED -> "vote_average.desc" + TmdbEntityRailType.RECENT -> "primary_release_date.desc" + } + + private fun tvSortBy(railType: TmdbEntityRailType): String = when (railType) { + TmdbEntityRailType.POPULAR -> "popularity.desc" + TmdbEntityRailType.TOP_RATED -> "vote_average.desc" + TmdbEntityRailType.RECENT -> "first_air_date.desc" + } + private fun buildShowYearRange(startYear: String, endYear: String?, status: String?): String { val isEnded = status != null && status != "Returning Series" && status != "In Production" return when { @@ -570,6 +839,8 @@ class TmdbMetadataService @Inject constructor( "pt" to "PT", "es" to "ES" ) + private const val ENTITY_RAIL_MAX_ITEMS = 20 + private const val TOP_RATED_VOTE_COUNT_FLOOR = 200 } suspend fun fetchPersonDetail( @@ -834,6 +1105,50 @@ data class TmdbEpisodeEnrichment( val runtimeMinutes: Int? ) +enum class TmdbEntityKind(val routeValue: String) { + COMPANY("company"), + NETWORK("network"); + + companion object { + fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase(Locale.US)) { + "network" -> NETWORK + else -> COMPANY + } + } +} + +enum class TmdbEntityMediaType(val value: String) { + MOVIE("movie"), + TV("tv") +} + +enum class TmdbEntityRailType(val value: String) { + POPULAR("popular"), + TOP_RATED("top_rated"), + RECENT("recent") +} + +data class TmdbEntityHeader( + val id: Int, + val kind: TmdbEntityKind, + val name: String, + val logo: String?, + val originCountry: String?, + val secondaryLabel: String?, + val description: String? +) + +data class TmdbEntityRail( + val mediaType: TmdbEntityMediaType, + val railType: TmdbEntityRailType, + val items: List +) + +data class TmdbEntityBrowseData( + val header: TmdbEntityHeader, + val rails: List +) + private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment { val title = name?.takeIf { it.isNotBlank() } val overview = overview?.takeIf { it.isNotBlank() } diff --git a/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt b/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt index d105a5f7..191aed1a 100644 --- a/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt +++ b/app/src/main/java/com/nuvio/tv/data/remote/api/TmdbApi.kt @@ -140,6 +140,41 @@ interface TmdbApi { @Query("api_key") apiKey: String, @Query("language") language: String? = null ): Response + + @GET("company/{company_id}") + suspend fun getCompanyDetails( + @Path("company_id") companyId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("network/{network_id}") + suspend fun getNetworkDetails( + @Path("network_id") networkId: Int, + @Query("api_key") apiKey: String + ): Response + + @GET("discover/movie") + suspend fun discoverMovies( + @Query("api_key") apiKey: String, + @Query("language") language: String? = null, + @Query("page") page: Int = 1, + @Query("sort_by") sortBy: String? = null, + @Query("with_companies") withCompanies: String? = null, + @Query("release_date.lte") releaseDateLte: String? = null, + @Query("vote_count.gte") voteCountGte: Int? = null + ): Response + + @GET("discover/tv") + suspend fun discoverTv( + @Query("api_key") apiKey: String, + @Query("language") language: String? = null, + @Query("page") page: Int = 1, + @Query("sort_by") sortBy: String? = null, + @Query("with_companies") withCompanies: String? = null, + @Query("with_networks") withNetworks: String? = null, + @Query("first_air_date.lte") firstAirDateLte: String? = null, + @Query("vote_count.gte") voteCountGte: Int? = null + ): Response } @JsonClass(generateAdapter = true) @@ -225,12 +260,14 @@ data class TmdbGenre( @JsonClass(generateAdapter = true) data class TmdbCompany( + @Json(name = "id") val id: Int? = null, @Json(name = "name") val name: String? = null, @Json(name = "logo_path") val logoPath: String? = null ) @JsonClass(generateAdapter = true) data class TmdbNetwork( + @Json(name = "id") val id: Int? = null, @Json(name = "name") val name: String? = null, @Json(name = "logo_path") val logoPath: String? = null ) @@ -325,6 +362,31 @@ data class TmdbRecommendationsResponse( @Json(name = "results") val results: List? = null ) +@JsonClass(generateAdapter = true) +data class TmdbDiscoverResponse( + @Json(name = "page") val page: Int? = null, + @Json(name = "results") val results: List? = null, + @Json(name = "total_pages") val totalPages: Int? = null, + @Json(name = "total_results") val totalResults: Int? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbDiscoverResult( + @Json(name = "id") val id: Int, + @Json(name = "title") val title: String? = null, + @Json(name = "name") val name: String? = null, + @Json(name = "original_title") val originalTitle: String? = null, + @Json(name = "original_name") val originalName: String? = null, + @Json(name = "poster_path") val posterPath: String? = null, + @Json(name = "backdrop_path") val backdropPath: String? = null, + @Json(name = "overview") val overview: String? = null, + @Json(name = "release_date") val releaseDate: String? = null, + @Json(name = "first_air_date") val firstAirDate: String? = null, + @Json(name = "vote_average") val voteAverage: Double? = null, + @Json(name = "vote_count") val voteCount: Int? = null, + @Json(name = "popularity") val popularity: Double? = null +) + @JsonClass(generateAdapter = true) data class TmdbRecommendationResult( @Json(name = "id") val id: Int, @@ -426,3 +488,23 @@ data class TmdbCollectionSummary( @Json(name = "backdrop_path") val backdropPath: String? = null ) +@JsonClass(generateAdapter = true) +data class TmdbCompanyDetailsResponse( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String? = null, + @Json(name = "description") val description: String? = null, + @Json(name = "headquarters") val headquarters: String? = null, + @Json(name = "homepage") val homepage: String? = null, + @Json(name = "logo_path") val logoPath: String? = null, + @Json(name = "origin_country") val originCountry: String? = null +) + +@JsonClass(generateAdapter = true) +data class TmdbNetworkDetailsResponse( + @Json(name = "id") val id: Int, + @Json(name = "name") val name: String? = null, + @Json(name = "headquarters") val headquarters: String? = null, + @Json(name = "homepage") val homepage: String? = null, + @Json(name = "logo_path") val logoPath: String? = null, + @Json(name = "origin_country") val originCountry: String? = null +) diff --git a/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt b/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt index 0db9d815..140dcfef 100644 --- a/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt +++ b/app/src/main/java/com/nuvio/tv/domain/model/Meta.kt @@ -64,7 +64,8 @@ data class MetaCastMember( @Immutable data class MetaCompany( val name: String, - val logo: String? = null + val logo: String? = null, + val tmdbId: Int? = null ) @Immutable diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt index 37a7f41b..6373019d 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/NuvioNavHost.kt @@ -40,6 +40,7 @@ import com.nuvio.tv.ui.screens.account.AuthQrSignInScreen import com.nuvio.tv.ui.screens.cast.CastDetailScreen import com.nuvio.tv.ui.screens.profile.ProfileSelectionMode import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen +import com.nuvio.tv.ui.screens.tmdb.TmdbEntityBrowseScreen @Composable fun NuvioNavHost( @@ -246,6 +247,16 @@ fun NuvioNavHost( onNavigateToCastDetail = { personId, personName, preferCrew -> navController.navigate(Screen.CastDetail.createRoute(personId, personName, preferCrew)) }, + onNavigateToTmdbEntityBrowse = { entityKind, entityId, entityName, sourceType -> + navController.navigate( + Screen.TmdbEntityBrowse.createRoute( + entityKind = entityKind, + entityId = entityId, + entityName = entityName, + sourceType = sourceType + ) + ) + }, onNavigateToDetail = { itemId, itemType, addonBaseUrl -> navController.navigate(Screen.Detail.createRoute(itemId, itemType, addonBaseUrl)) }, @@ -979,5 +990,25 @@ fun NuvioNavHost( } ) } + + composable( + route = Screen.TmdbEntityBrowse.route, + arguments = listOf( + navArgument("entityKind") { type = NavType.StringType }, + navArgument("entityId") { type = NavType.IntType }, + navArgument("entityName") { type = NavType.StringType }, + navArgument("sourceType") { + type = NavType.StringType + defaultValue = "tv" + } + ) + ) { + TmdbEntityBrowseScreen( + onBackPress = { navController.popBackStack() }, + onNavigateToDetail = { itemId, itemType, addonBaseUrl -> + navController.navigate(Screen.Detail.createRoute(itemId, itemType, addonBaseUrl)) + } + ) + } } } diff --git a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt index a4619da2..6668bad1 100644 --- a/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/navigation/Screen.kt @@ -160,4 +160,20 @@ sealed class Screen(val route: String) { return "cast_detail/$personId/${encode(personName)}?preferCrew=$preferCrew" } } + + data object TmdbEntityBrowse : Screen( + "tmdb_entity_browse/{entityKind}/{entityId}/{entityName}?sourceType={sourceType}" + ) { + private fun encode(value: String): String = + URLEncoder.encode(value, "UTF-8").replace("+", "%20") + + fun createRoute( + entityKind: String, + entityId: Int, + entityName: String, + sourceType: String + ): String { + return "tmdb_entity_browse/${encode(entityKind)}/$entityId/${encode(entityName)}?sourceType=${encode(sourceType)}" + } + } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/CompanyLogosSection.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/CompanyLogosSection.kt index 38f90f56..ec7dd723 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/CompanyLogosSection.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/CompanyLogosSection.kt @@ -13,13 +13,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow @@ -42,10 +46,28 @@ import com.nuvio.tv.ui.theme.NuvioTheme @Composable fun CompanyLogosSection( title: String, - companies: List + companies: List, + onCompanyClick: (MetaCompany) -> Unit = {}, + restoreCompanyId: Int? = null, + restoreFocusToken: Int = 0, + onRestoreFocusHandled: () -> Unit = {} ) { if (companies.isEmpty()) return + val focusRequesters = remember(companies) { + companies + .mapNotNull { company -> company.tmdbId?.let { it to FocusRequester() } } + .toMap() + } + + LaunchedEffect(restoreCompanyId, restoreFocusToken) { + if (restoreFocusToken <= 0 || restoreCompanyId == null) return@LaunchedEffect + val targetRequester = focusRequesters[restoreCompanyId] ?: return@LaunchedEffect + repeat(2) { withFrameNanos { } } + runCatching { targetRequester.requestFocus() } + onRestoreFocusHandled() + } + Column( modifier = Modifier .fillMaxWidth() @@ -69,14 +91,22 @@ fun CompanyLogosSection( "$title-$index-${company.name}-${company.logo.orEmpty()}" } ) { _, company -> - CompanyLogoCard(company = company) + CompanyLogoCard( + company = company, + focusRequester = focusRequesters[company.tmdbId], + onClick = { onCompanyClick(company) } + ) } } } } @Composable -private fun CompanyLogoCard(company: MetaCompany) { +private fun CompanyLogoCard( + company: MetaCompany, + focusRequester: FocusRequester? = null, + onClick: () -> Unit +) { val context = LocalContext.current val density = LocalDensity.current val logoWidthPx = remember(density) { with(density) { 140.dp.roundToPx() } } @@ -93,10 +123,17 @@ private fun CompanyLogoCard(company: MetaCompany) { var logoLoadFailed by remember(company.logo) { mutableStateOf(false) } Card( - onClick = { }, + onClick = { + if (company.tmdbId != null) { + onClick() + } + }, modifier = Modifier .width(140.dp) - .height(56.dp), + .height(56.dp) + .then( + if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier + ), colors = CardDefaults.colors( containerColor = Color.White, focusedContainerColor = Color.White diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt index 149c0244..092ffc2b 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsScreen.kt @@ -111,7 +111,8 @@ private enum class RestoreTarget { EPISODE, CAST_MEMBER, MORE_LIKE_THIS, - COLLECTION + COLLECTION, + COMPANY_OR_NETWORK } private enum class PeopleSectionTab { @@ -189,6 +190,7 @@ fun MetaDetailsScreen( returnFocusEpisode: Int? = null, onBackPress: () -> Unit, onNavigateToCastDetail: (personId: Int, personName: String, preferCrew: Boolean) -> Unit = { _, _, _ -> }, + onNavigateToTmdbEntityBrowse: (entityKind: String, entityId: Int, entityName: String, sourceType: String) -> Unit = { _, _, _, _ -> }, onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit = { _, _, _ -> }, onPlayClick: ( videoId: String, @@ -529,6 +531,7 @@ fun MetaDetailsScreen( onTrailerButtonClick = { viewModel.onEvent(MetaDetailsEvent.OnTrailerButtonClick) }, restorePlayFocusAfterTrailerBackToken = restorePlayFocusAfterTrailerBackToken, onNavigateToCastDetail = onNavigateToCastDetail, + onNavigateToTmdbEntityBrowse = onNavigateToTmdbEntityBrowse, onNavigateToDetail = onNavigateToDetail ) } @@ -650,6 +653,7 @@ private fun MetaDetailsContent( onTrailerButtonClick: () -> Unit, restorePlayFocusAfterTrailerBackToken: Int, onNavigateToCastDetail: (personId: Int, personName: String, preferCrew: Boolean) -> Unit = { _, _, _ -> }, + onNavigateToTmdbEntityBrowse: (entityKind: String, entityId: Int, entityName: String, sourceType: String) -> Unit = { _, _, _, _ -> }, onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit = { _, _, _ -> } ) { val isSeries = remember(meta.type, meta.videos) { @@ -700,6 +704,8 @@ private fun MetaDetailsContent( var pendingRestoreEpisodeId by rememberSaveable { mutableStateOf(null) } var pendingRestoreCastPersonId by rememberSaveable { mutableStateOf(null) } var pendingRestoreMoreLikeItemId by rememberSaveable { mutableStateOf(null) } + var pendingRestoreCollectionItemId by rememberSaveable { mutableStateOf(null) } + var pendingRestoreCompanyId by rememberSaveable { mutableStateOf(null) } var restoreFocusToken by rememberSaveable { mutableIntStateOf(0) } var initialHeroFocusRequested by rememberSaveable(meta.id) { mutableStateOf(false) } var showHeroPlayOptionsDialog by rememberSaveable(meta.id) { mutableStateOf(false) } @@ -718,6 +724,8 @@ private fun MetaDetailsContent( pendingRestoreEpisodeId = null pendingRestoreCastPersonId = null pendingRestoreMoreLikeItemId = null + pendingRestoreCollectionItemId = null + pendingRestoreCompanyId = null } fun markHeroRestore() { @@ -725,6 +733,8 @@ private fun MetaDetailsContent( pendingRestoreEpisodeId = null pendingRestoreCastPersonId = null pendingRestoreMoreLikeItemId = null + pendingRestoreCollectionItemId = null + pendingRestoreCompanyId = null } fun markEpisodeRestore(episodeId: String) { @@ -732,6 +742,8 @@ private fun MetaDetailsContent( pendingRestoreEpisodeId = episodeId pendingRestoreCastPersonId = null pendingRestoreMoreLikeItemId = null + pendingRestoreCollectionItemId = null + pendingRestoreCompanyId = null } fun markCastMemberRestore(personId: Int) { @@ -739,6 +751,8 @@ private fun MetaDetailsContent( pendingRestoreEpisodeId = null pendingRestoreCastPersonId = personId pendingRestoreMoreLikeItemId = null + pendingRestoreCollectionItemId = null + pendingRestoreCompanyId = null } fun markMoreLikeThisRestore(itemId: String) { @@ -746,15 +760,26 @@ private fun MetaDetailsContent( pendingRestoreEpisodeId = null pendingRestoreCastPersonId = null pendingRestoreMoreLikeItemId = itemId + pendingRestoreCollectionItemId = null + pendingRestoreCompanyId = null } - var pendingRestoreCollectionItemId by rememberSaveable { mutableStateOf(null) } fun markCollectionRestore(itemId: String) { pendingRestoreType = RestoreTarget.COLLECTION pendingRestoreEpisodeId = null pendingRestoreCastPersonId = null pendingRestoreMoreLikeItemId = null pendingRestoreCollectionItemId = itemId + pendingRestoreCompanyId = null + } + + fun markCompanyRestore(companyId: Int) { + pendingRestoreType = RestoreTarget.COMPANY_OR_NETWORK + pendingRestoreEpisodeId = null + pendingRestoreCastPersonId = null + pendingRestoreMoreLikeItemId = null + pendingRestoreCollectionItemId = null + pendingRestoreCompanyId = companyId } DisposableEffect( @@ -763,7 +788,8 @@ private fun MetaDetailsContent( pendingRestoreEpisodeId, pendingRestoreCastPersonId, pendingRestoreMoreLikeItemId, - pendingRestoreCollectionItemId + pendingRestoreCollectionItemId, + pendingRestoreCompanyId ) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME && pendingRestoreType != null) { @@ -1376,7 +1402,16 @@ private fun MetaDetailsContent( item(key = "networks", contentType = "horizontal_row") { CompanyLogosSection( title = stringResource(R.string.detail_section_network), - companies = meta.networks + companies = meta.networks, + restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null, + restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0, + onRestoreFocusHandled = { clearPendingRestore() }, + onCompanyClick = { company -> + company.tmdbId?.let { entityId -> + markCompanyRestore(entityId) + onNavigateToTmdbEntityBrowse("network", entityId, company.name, meta.apiType) + } + } ) } } @@ -1385,7 +1420,16 @@ private fun MetaDetailsContent( item(key = "production", contentType = "horizontal_row") { CompanyLogosSection( title = stringResource(R.string.detail_section_production), - companies = meta.productionCompanies + companies = meta.productionCompanies, + restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null, + restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0, + onRestoreFocusHandled = { clearPendingRestore() }, + onCompanyClick = { company -> + company.tmdbId?.let { entityId -> + markCompanyRestore(entityId) + onNavigateToTmdbEntityBrowse("company", entityId, company.name, meta.apiType) + } + } ) } } @@ -1394,7 +1438,16 @@ private fun MetaDetailsContent( item(key = "production", contentType = "horizontal_row") { CompanyLogosSection( title = stringResource(R.string.detail_section_production), - companies = meta.productionCompanies + companies = meta.productionCompanies, + restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null, + restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0, + onRestoreFocusHandled = { clearPendingRestore() }, + onCompanyClick = { company -> + company.tmdbId?.let { entityId -> + markCompanyRestore(entityId) + onNavigateToTmdbEntityBrowse("company", entityId, company.name, meta.apiType) + } + } ) } } @@ -1403,7 +1456,16 @@ private fun MetaDetailsContent( item(key = "networks", contentType = "horizontal_row") { CompanyLogosSection( title = stringResource(R.string.detail_section_network), - companies = meta.networks + companies = meta.networks, + restoreCompanyId = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) pendingRestoreCompanyId else null, + restoreFocusToken = if (pendingRestoreType == RestoreTarget.COMPANY_OR_NETWORK) restoreFocusToken else 0, + onRestoreFocusHandled = { clearPendingRestore() }, + onCompanyClick = { company -> + company.tmdbId?.let { entityId -> + markCompanyRestore(entityId) + onNavigateToTmdbEntityBrowse("network", entityId, company.name, meta.apiType) + } + } ) } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseScreen.kt new file mode 100644 index 00000000..b55947b7 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseScreen.kt @@ -0,0 +1,381 @@ +package com.nuvio.tv.ui.screens.tmdb + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.Crossfade +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.nuvio.tv.R +import com.nuvio.tv.core.tmdb.TmdbEntityBrowseData +import com.nuvio.tv.core.tmdb.TmdbEntityKind +import com.nuvio.tv.core.tmdb.TmdbEntityMediaType +import com.nuvio.tv.core.tmdb.TmdbEntityRail +import com.nuvio.tv.core.tmdb.TmdbEntityRailType +import com.nuvio.tv.domain.model.MetaPreview +import com.nuvio.tv.ui.components.EmptyScreenState +import com.nuvio.tv.ui.components.ErrorState +import com.nuvio.tv.ui.components.GridContentCard +import com.nuvio.tv.ui.components.LoadingIndicator +import com.nuvio.tv.ui.components.PosterCardDefaults +import com.nuvio.tv.ui.components.PosterCardStyle +import com.nuvio.tv.ui.theme.NuvioColors + +@Composable +fun TmdbEntityBrowseScreen( + viewModel: TmdbEntityBrowseViewModel = hiltViewModel(), + onBackPress: () -> Unit, + onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + BackHandler { onBackPress() } + + Box( + modifier = Modifier + .fillMaxSize() + .background(NuvioColors.Background) + ) { + Crossfade( + targetState = uiState, + label = "TmdbEntityBrowseState" + ) { state -> + when (state) { + TmdbEntityBrowseUiState.Loading -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + LoadingIndicator() + } + } + + is TmdbEntityBrowseUiState.Error -> { + ErrorState( + message = state.message, + onRetry = { viewModel.retry() } + ) + } + + is TmdbEntityBrowseUiState.Success -> { + TmdbEntityBrowseContent( + data = state.data, + sourceType = viewModel.sourceType, + onNavigateToDetail = onNavigateToDetail + ) + } + } + } + } +} + +@Composable +private fun TmdbEntityBrowseContent( + data: TmdbEntityBrowseData, + sourceType: String, + onNavigateToDetail: (itemId: String, itemType: String, addonBaseUrl: String?) -> Unit +) { + val lifecycleOwner = LocalLifecycleOwner.current + var pendingRestoreItemId by rememberSaveable(data.header.id) { mutableStateOf(null) } + var restoreFocusToken by rememberSaveable(data.header.id) { mutableIntStateOf(0) } + + DisposableEffect(lifecycleOwner, pendingRestoreItemId) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME && pendingRestoreItemId != null) { + restoreFocusToken += 1 + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + val posterCardStyle = PosterCardDefaults.Style + + val backgroundRequest = rememberBackgroundRequest( + data = data, + sourceType = sourceType + ) + + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + // Background image layer + if (backgroundRequest != null) { + AsyncImage( + model = backgroundRequest, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + alpha = 0.34f + ) + } + + // Gradient overlay + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + NuvioColors.Background.copy(alpha = 0.35f), + NuvioColors.Background.copy(alpha = 0.75f), + NuvioColors.Background + ) + ) + ) + ) + + if (data.rails.isEmpty()) { + EmptyScreenState( + title = stringResource(R.string.tmdb_entity_empty_title), + subtitle = stringResource(R.string.tmdb_entity_empty_subtitle), + modifier = Modifier.align(Alignment.Center) + ) + } else { + val railsViewportFraction = 0.55f + val railsViewportHeight = maxHeight * railsViewportFraction + + // Fixed hero in the upper area + TmdbEntityHero( + data = data, + modifier = Modifier + .align(Alignment.TopStart) + .padding(top = 40.dp, bottom = 16.dp) + ) + + // Scrollable rails anchored to the bottom + LazyColumn( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(railsViewportHeight), + contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + items( + items = data.rails, + key = { rail -> "${rail.mediaType.value}_${rail.railType.value}" } + ) { rail -> + EntityRailRow( + rail = rail, + posterCardStyle = posterCardStyle, + restoreItemId = pendingRestoreItemId, + restoreFocusToken = restoreFocusToken, + onRestoreFocusHandled = { pendingRestoreItemId = null }, + onItemClick = { item -> + pendingRestoreItemId = item.id + onNavigateToDetail(item.id, item.apiType, null) + } + ) + } + } + } + } +} + +@Composable +private fun rememberBackgroundRequest( + data: TmdbEntityBrowseData, + sourceType: String +) : ImageRequest? { + val context = LocalContext.current + return remember(context, data, sourceType) { + val backgroundItem = data.rails.firstOrNull { + it.mediaType == if (sourceType.trim().equals("movie", ignoreCase = true)) { + TmdbEntityMediaType.MOVIE + } else { + TmdbEntityMediaType.TV + } + }?.items?.firstOrNull()?.background ?: data.rails.firstOrNull()?.items?.firstOrNull()?.background + backgroundItem?.let { + ImageRequest.Builder(context) + .data(it) + .crossfade(true) + .build() + } + } +} + +@Composable +private fun TmdbEntityHero( + data: TmdbEntityBrowseData, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val context = LocalContext.current + if (!data.header.logo.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(data.header.logo) + .crossfade(true) + .build(), + contentDescription = data.header.name, + modifier = Modifier + .width(220.dp) + .height(90.dp), + contentScale = ContentScale.Fit + ) + Spacer(modifier = Modifier.width(24.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + Text( + text = entityKindLabel(data.header.kind), + style = MaterialTheme.typography.labelLarge, + color = NuvioColors.TextSecondary + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = data.header.name, + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp + ), + color = NuvioColors.TextPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + val metaLine = listOfNotNull( + data.header.originCountry?.takeIf { it.isNotBlank() }, + data.header.secondaryLabel?.takeIf { it.isNotBlank() } + ).joinToString(" • ") + if (metaLine.isNotBlank()) { + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = metaLine, + style = MaterialTheme.typography.bodyMedium, + color = NuvioColors.TextSecondary + ) + } + data.header.description?.takeIf { it.isNotBlank() }?.let { description -> + Spacer(modifier = Modifier.height(14.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium.copy( + lineHeight = 20.sp + ), + color = NuvioColors.TextSecondary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(0.72f) + ) + } + } + } +} + +@Composable +private fun EntityRailRow( + rail: TmdbEntityRail, + posterCardStyle: PosterCardStyle, + restoreItemId: String?, + restoreFocusToken: Int, + onRestoreFocusHandled: () -> Unit, + onItemClick: (MetaPreview) -> Unit +) { + val focusRequesters = remember(rail.items) { + rail.items.associate { it.id to FocusRequester() } + } + + LaunchedEffect(restoreItemId, restoreFocusToken) { + if (restoreFocusToken <= 0 || restoreItemId == null) return@LaunchedEffect + val requester = focusRequesters[restoreItemId] ?: return@LaunchedEffect + repeat(2) { withFrameNanos { } } + runCatching { requester.requestFocus() } + onRestoreFocusHandled() + } + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = railTitle(rail), + style = MaterialTheme.typography.titleLarge.copy( + fontWeight = FontWeight.SemiBold + ), + color = NuvioColors.TextPrimary, + modifier = Modifier.padding(horizontal = 48.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + LazyRow( + contentPadding = PaddingValues(horizontal = 48.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = rail.items, + key = { item -> item.id } + ) { item -> + GridContentCard( + item = item, + onClick = { onItemClick(item) }, + posterCardStyle = posterCardStyle, + showLabel = true, + focusRequester = focusRequesters[item.id] + ) + } + } + } +} + +@Composable +private fun entityKindLabel(kind: TmdbEntityKind): String = when (kind) { + TmdbEntityKind.COMPANY -> stringResource(R.string.tmdb_entity_kind_company) + TmdbEntityKind.NETWORK -> stringResource(R.string.tmdb_entity_kind_network) +} + +@Composable +private fun railTitle(rail: TmdbEntityRail): String { + val mediaLabel = when (rail.mediaType) { + TmdbEntityMediaType.MOVIE -> stringResource(R.string.type_movie) + TmdbEntityMediaType.TV -> stringResource(R.string.type_series) + } + val railLabel = when (rail.railType) { + TmdbEntityRailType.POPULAR -> stringResource(R.string.tmdb_entity_rail_popular) + TmdbEntityRailType.TOP_RATED -> stringResource(R.string.tmdb_entity_rail_top_rated) + TmdbEntityRailType.RECENT -> stringResource(R.string.tmdb_entity_rail_recent) + } + return "$mediaLabel • $railLabel" +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseUiState.kt new file mode 100644 index 00000000..ff87ab57 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseUiState.kt @@ -0,0 +1,9 @@ +package com.nuvio.tv.ui.screens.tmdb + +import com.nuvio.tv.core.tmdb.TmdbEntityBrowseData + +sealed interface TmdbEntityBrowseUiState { + data object Loading : TmdbEntityBrowseUiState + data class Error(val message: String) : TmdbEntityBrowseUiState + data class Success(val data: TmdbEntityBrowseData) : TmdbEntityBrowseUiState +} diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseViewModel.kt new file mode 100644 index 00000000..36b13f85 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/ui/screens/tmdb/TmdbEntityBrowseViewModel.kt @@ -0,0 +1,76 @@ +package com.nuvio.tv.ui.screens.tmdb + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nuvio.tv.core.tmdb.TmdbEntityKind +import com.nuvio.tv.core.tmdb.TmdbMetadataService +import com.nuvio.tv.data.local.TmdbSettingsDataStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import java.net.URLDecoder +import javax.inject.Inject + +@HiltViewModel +class TmdbEntityBrowseViewModel @Inject constructor( + private val tmdbMetadataService: TmdbMetadataService, + private val tmdbSettingsDataStore: TmdbSettingsDataStore, + savedStateHandle: SavedStateHandle +) : ViewModel() { + + val entityKind: TmdbEntityKind = TmdbEntityKind.fromRouteValue( + savedStateHandle.get("entityKind").orEmpty() + ) + val entityId: Int = savedStateHandle.get("entityId") ?: 0 + val entityName: String = URLDecoder.decode( + savedStateHandle.get("entityName").orEmpty(), + "UTF-8" + ) + val sourceType: String = savedStateHandle.get("sourceType").orEmpty() + + private val _uiState = MutableStateFlow(TmdbEntityBrowseUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun retry() { + _uiState.value = TmdbEntityBrowseUiState.Loading + load() + } + + private fun load() { + viewModelScope.launch { + try { + val language = tmdbSettingsDataStore.settings.first().language + val browseData = tmdbMetadataService.fetchEntityBrowse( + entityKind = entityKind, + entityId = entityId, + sourceType = sourceType, + fallbackName = entityName, + language = language + ) + _uiState.value = if (browseData != null) { + TmdbEntityBrowseUiState.Success(browseData) + } else { + TmdbEntityBrowseUiState.Error( + if (entityName.isNotBlank()) { + "Could not load $entityName" + } else { + "Could not load TMDB entity" + } + ) + } + } catch (e: Exception) { + _uiState.value = TmdbEntityBrowseUiState.Error( + e.message ?: "Could not load TMDB entity" + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 291409c9..c82ccbe6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,6 +124,13 @@ More like this Network Production + Production Company + Network + Popular + Top Rated + Recent + No titles found + TMDB does not currently have browseable titles for this selection. Unavailable Ended Ongoing diff --git a/app/src/test/java/com/nuvio/tv/core/tmdb/TmdbMetadataServiceTest.kt b/app/src/test/java/com/nuvio/tv/core/tmdb/TmdbMetadataServiceTest.kt new file mode 100644 index 00000000..984a2a12 --- /dev/null +++ b/app/src/test/java/com/nuvio/tv/core/tmdb/TmdbMetadataServiceTest.kt @@ -0,0 +1,230 @@ +package com.nuvio.tv.core.tmdb + +import com.nuvio.tv.data.remote.api.TmdbApi +import com.nuvio.tv.data.remote.api.TmdbCompany +import com.nuvio.tv.data.remote.api.TmdbCompanyDetailsResponse +import com.nuvio.tv.data.remote.api.TmdbCreditsResponse +import com.nuvio.tv.data.remote.api.TmdbDetailsResponse +import com.nuvio.tv.data.remote.api.TmdbDiscoverResponse +import com.nuvio.tv.data.remote.api.TmdbDiscoverResult +import com.nuvio.tv.data.remote.api.TmdbImagesResponse +import com.nuvio.tv.data.remote.api.TmdbMovieReleaseDatesResponse +import com.nuvio.tv.data.remote.api.TmdbNetwork +import com.nuvio.tv.data.remote.api.TmdbNetworkDetailsResponse +import com.nuvio.tv.domain.model.ContentType +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class TmdbMetadataServiceTest { + + @Test + fun `fetchEnrichment maps tmdb ids onto production and network companies`() = runTest { + val api = mockk() + coEvery { api.getMovieDetails(any(), any(), any()) } returns Response.success( + TmdbDetailsResponse( + id = 10, + productionCompanies = listOf( + TmdbCompany(id = 55, name = "Acme Pictures", logoPath = "/company.png") + ), + networks = listOf( + TmdbNetwork(id = 77, name = "Prime TV", logoPath = "/network.png") + ) + ) + ) + coEvery { api.getMovieCredits(any(), any(), any()) } returns Response.success(TmdbCreditsResponse()) + coEvery { api.getMovieImages(any(), any(), any()) } returns Response.success(TmdbImagesResponse()) + coEvery { api.getMovieReleaseDates(any(), any()) } returns Response.success(TmdbMovieReleaseDatesResponse()) + + val service = TmdbMetadataService(api) + + val enrichment = service.fetchEnrichment( + tmdbId = "10", + contentType = ContentType.MOVIE, + language = "en" + ) + + assertNotNull(enrichment) + assertEquals(55, enrichment?.productionCompanies?.firstOrNull()?.tmdbId) + assertEquals(77, enrichment?.networks?.firstOrNull()?.tmdbId) + } + + @Test + fun `company browse requests movie then tv rails when source type is movie`() = runTest { + val api = mockk() + val movieCalls = mutableListOf() + val tvCalls = mutableListOf() + + coEvery { api.getCompanyDetails(99, any()) } returns Response.success( + TmdbCompanyDetailsResponse( + id = 99, + name = "Acme Pictures", + originCountry = "US" + ) + ) + coEvery { + api.discoverMovies(any(), any(), any(), any(), any(), any(), any()) + } answers { + movieCalls += MovieDiscoverCall( + sortBy = arg(3), + withCompanies = arg(4), + releaseDateLte = arg(5), + voteCountGte = arg(6) + ) + Response.success( + TmdbDiscoverResponse( + results = listOf( + TmdbDiscoverResult( + id = movieCalls.size, + title = "Movie ${movieCalls.size}", + posterPath = "/movie-${movieCalls.size}.jpg", + backdropPath = "/movie-bg-${movieCalls.size}.jpg", + releaseDate = "2024-01-01", + voteAverage = 7.4 + ) + ) + ) + ) + } + coEvery { + api.discoverTv(any(), any(), any(), any(), any(), any(), any(), any()) + } answers { + tvCalls += TvDiscoverCall( + sortBy = arg(3), + withCompanies = arg(4), + withNetworks = arg(5), + firstAirDateLte = arg(6), + voteCountGte = arg(7) + ) + Response.success( + TmdbDiscoverResponse( + results = listOf( + TmdbDiscoverResult( + id = 100 + tvCalls.size, + name = "Show ${tvCalls.size}", + posterPath = "/show-${tvCalls.size}.jpg", + backdropPath = "/show-bg-${tvCalls.size}.jpg", + firstAirDate = "2023-03-10", + voteAverage = 8.0 + ) + ) + ) + ) + } + + val service = TmdbMetadataService(api) + + val data = service.fetchEntityBrowse( + entityKind = TmdbEntityKind.COMPANY, + entityId = 99, + sourceType = "movie", + fallbackName = "Acme Pictures", + language = "en" + ) + + assertNotNull(data) + assertEquals( + listOf( + TmdbEntityMediaType.MOVIE, + TmdbEntityMediaType.MOVIE, + TmdbEntityMediaType.MOVIE, + TmdbEntityMediaType.TV, + TmdbEntityMediaType.TV, + TmdbEntityMediaType.TV + ), + data?.rails?.map { it.mediaType } + ) + assertEquals(3, movieCalls.size) + assertEquals(3, tvCalls.size) + assertTrue(movieCalls.all { it.withCompanies == "99" }) + assertTrue(tvCalls.all { it.withCompanies == "99" }) + assertTrue(tvCalls.all { it.withNetworks == null }) + assertEquals(200, movieCalls.first { it.sortBy == "vote_average.desc" }.voteCountGte) + assertTrue(movieCalls.first { it.sortBy == "primary_release_date.desc" }.releaseDateLte != null) + assertTrue(tvCalls.first { it.sortBy == "first_air_date.desc" }.firstAirDateLte != null) + assertTrue(data?.rails?.flatMap { it.items }.orEmpty().all { it.id.startsWith("tmdb:") }) + } + + @Test + fun `network browse only requests tv rails and scopes by network id`() = runTest { + val api = mockk() + val tvCalls = mutableListOf() + + coEvery { api.getNetworkDetails(77, any()) } returns Response.success( + TmdbNetworkDetailsResponse( + id = 77, + name = "Prime TV", + originCountry = "US" + ) + ) + coEvery { + api.discoverTv(any(), any(), any(), any(), any(), any(), any(), any()) + } answers { + tvCalls += TvDiscoverCall( + sortBy = arg(3), + withCompanies = arg(4), + withNetworks = arg(5), + firstAirDateLte = arg(6), + voteCountGte = arg(7) + ) + Response.success( + TmdbDiscoverResponse( + results = listOf( + TmdbDiscoverResult( + id = 500 + tvCalls.size, + name = "Network Show ${tvCalls.size}", + posterPath = "/network-${tvCalls.size}.jpg", + backdropPath = "/network-bg-${tvCalls.size}.jpg", + firstAirDate = "2022-07-01", + voteAverage = 8.3 + ) + ) + ) + ) + } + coEvery { + api.discoverMovies(any(), any(), any(), any(), any(), any(), any()) + } throws AssertionError("movie discovery must not run for networks") + + val service = TmdbMetadataService(api) + + val data = service.fetchEntityBrowse( + entityKind = TmdbEntityKind.NETWORK, + entityId = 77, + sourceType = "series", + fallbackName = "Prime TV", + language = "en" + ) + + assertNotNull(data) + assertEquals(3, tvCalls.size) + assertTrue(data?.rails?.all { it.mediaType == TmdbEntityMediaType.TV } == true) + assertTrue(tvCalls.all { it.withNetworks == "77" }) + assertTrue(tvCalls.all { it.withCompanies == null }) + assertNull(tvCalls.firstOrNull { it.sortBy == "popularity.desc" }?.voteCountGte) + assertEquals(200, tvCalls.first { it.sortBy == "vote_average.desc" }.voteCountGte) + } + + private data class MovieDiscoverCall( + val sortBy: String?, + val withCompanies: String?, + val releaseDateLte: String?, + val voteCountGte: Int? + ) + + private data class TvDiscoverCall( + val sortBy: String?, + val withCompanies: String?, + val withNetworks: String?, + val firstAirDateLte: String?, + val voteCountGte: Int? + ) +}