feat: Introduce TMDB entity browsing screens
This commit is contained in:
parent
acb5eaceb8
commit
2ed5dcafe4
12 changed files with 1262 additions and 15 deletions
|
|
@ -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<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
|
||||
private val personCache = ConcurrentHashMap<String, PersonDetail>()
|
||||
private val moreLikeThisCache = ConcurrentHashMap<String, List<MetaPreview>>()
|
||||
private val entityHeaderCache = ConcurrentHashMap<String, TmdbEntityHeader>()
|
||||
private val entityRailCache = ConcurrentHashMap<String, List<MetaPreview>>()
|
||||
private val entityBrowseCache = ConcurrentHashMap<String, TmdbEntityBrowseData>()
|
||||
|
||||
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<MetaPreview> {
|
||||
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<TmdbEntityMediaType> {
|
||||
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<MetaPreview>
|
||||
)
|
||||
|
||||
data class TmdbEntityBrowseData(
|
||||
val header: TmdbEntityHeader,
|
||||
val rails: List<TmdbEntityRail>
|
||||
)
|
||||
|
||||
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
|
||||
val title = name?.takeIf { it.isNotBlank() }
|
||||
val overview = overview?.takeIf { it.isNotBlank() }
|
||||
|
|
|
|||
|
|
@ -140,6 +140,41 @@ interface TmdbApi {
|
|||
@Query("api_key") apiKey: String,
|
||||
@Query("language") language: String? = null
|
||||
): Response<TmdbPersonCreditsResponse>
|
||||
|
||||
@GET("company/{company_id}")
|
||||
suspend fun getCompanyDetails(
|
||||
@Path("company_id") companyId: Int,
|
||||
@Query("api_key") apiKey: String
|
||||
): Response<TmdbCompanyDetailsResponse>
|
||||
|
||||
@GET("network/{network_id}")
|
||||
suspend fun getNetworkDetails(
|
||||
@Path("network_id") networkId: Int,
|
||||
@Query("api_key") apiKey: String
|
||||
): Response<TmdbNetworkDetailsResponse>
|
||||
|
||||
@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<TmdbDiscoverResponse>
|
||||
|
||||
@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<TmdbDiscoverResponse>
|
||||
}
|
||||
|
||||
@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<TmdbRecommendationResult>? = null
|
||||
)
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class TmdbDiscoverResponse(
|
||||
@Json(name = "page") val page: Int? = null,
|
||||
@Json(name = "results") val results: List<TmdbDiscoverResult>? = 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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<MetaCompany>
|
||||
companies: List<MetaCompany>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<String?>(null) }
|
||||
var pendingRestoreCastPersonId by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
var pendingRestoreMoreLikeItemId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingRestoreCollectionItemId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var pendingRestoreCompanyId by rememberSaveable { mutableStateOf<Int?>(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<String?>(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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String?>(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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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<String>("entityKind").orEmpty()
|
||||
)
|
||||
val entityId: Int = savedStateHandle.get<Int>("entityId") ?: 0
|
||||
val entityName: String = URLDecoder.decode(
|
||||
savedStateHandle.get<String>("entityName").orEmpty(),
|
||||
"UTF-8"
|
||||
)
|
||||
val sourceType: String = savedStateHandle.get<String>("sourceType").orEmpty()
|
||||
|
||||
private val _uiState = MutableStateFlow<TmdbEntityBrowseUiState>(TmdbEntityBrowseUiState.Loading)
|
||||
val uiState: StateFlow<TmdbEntityBrowseUiState> = _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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +124,13 @@
|
|||
<string name="detail_tab_more_like_this">More like this</string>
|
||||
<string name="detail_section_network">Network</string>
|
||||
<string name="detail_section_production">Production</string>
|
||||
<string name="tmdb_entity_kind_company">Production Company</string>
|
||||
<string name="tmdb_entity_kind_network">Network</string>
|
||||
<string name="tmdb_entity_rail_popular">Popular</string>
|
||||
<string name="tmdb_entity_rail_top_rated">Top Rated</string>
|
||||
<string name="tmdb_entity_rail_recent">Recent</string>
|
||||
<string name="tmdb_entity_empty_title">No titles found</string>
|
||||
<string name="tmdb_entity_empty_subtitle">TMDB does not currently have browseable titles for this selection.</string>
|
||||
<string name="episodes_unavailable">Unavailable</string>
|
||||
<string name="series_status_ended">Ended</string>
|
||||
<string name="series_status_continuing">Ongoing</string>
|
||||
|
|
|
|||
|
|
@ -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<TmdbApi>()
|
||||
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<TmdbApi>()
|
||||
val movieCalls = mutableListOf<MovieDiscoverCall>()
|
||||
val tvCalls = mutableListOf<TvDiscoverCall>()
|
||||
|
||||
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<TmdbApi>()
|
||||
val tvCalls = mutableListOf<TvDiscoverCall>()
|
||||
|
||||
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?
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue