diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 19cb92274..816c0dd52 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -81,6 +81,8 @@ import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.PersonDetailScreen +import com.nuvio.app.features.details.TmdbEntityBrowseScreen +import com.nuvio.app.features.tmdb.TmdbEntityKind import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.HomeScreen import com.nuvio.app.features.home.MetaPreview @@ -143,6 +145,14 @@ data class PersonDetailRoute( val preferCrew: Boolean = false, ) +@Serializable +data class EntityBrowseRoute( + val entityKind: String, + val entityId: Int, + val entityName: String, + val sourceType: String = "tv", +) + @Serializable object HomescreenSettingsRoute @@ -632,6 +642,19 @@ private fun MainAppContent( ) } }, + onCompanyClick = { company, entityKind -> + val tmdbId = company.tmdbId + if (tmdbId != null && tmdbId > 0) { + navController.navigate( + EntityBrowseRoute( + entityKind = entityKind, + entityId = tmdbId, + entityName = company.name, + sourceType = route.type, + ), + ) + } + }, modifier = Modifier.fillMaxSize(), ) } @@ -666,6 +689,38 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + TmdbEntityBrowseScreen( + entityKind = TmdbEntityKind.fromRouteValue(route.entityKind), + entityId = route.entityId, + entityName = route.entityName, + sourceType = route.sourceType, + onBack = { navController.popBackStack() }, + onOpenMeta = { preview -> + coroutineScope.launch { + val resolvedId = if (preview.id.startsWith("tmdb:")) { + val tmdbId = preview.id.removePrefix("tmdb:").toIntOrNull() + tmdbId?.let { + TmdbService.tmdbToImdb( + tmdbId = it, + mediaType = preview.type, + ) + } ?: preview.id + } else { + preview.id + } + navController.navigate( + DetailRoute( + type = preview.type, + id = resolvedId, + ), + ) + } + }, + modifier = Modifier.fillMaxSize(), + ) + } composable { backStackEntry -> val route = backStackEntry.toRoute() val pauseDescription = remember(route.streamContextId) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 1ab77f245..eab575054 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -90,6 +90,7 @@ fun MetaDetailsScreen( onPlay: ((type: String, videoId: String, parentMetaId: String, parentMetaType: String, title: String, logo: String?, poster: String?, background: String?, seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, episodeThumbnail: String?, pauseDescription: String?, resumePositionMs: Long?) -> Unit)? = null, onOpenMeta: ((MetaPreview) -> Unit)? = null, onCastClick: ((MetaPerson) -> Unit)? = null, + onCompanyClick: ((MetaCompany, String) -> Unit)? = null, modifier: Modifier = Modifier, ) { val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() @@ -516,6 +517,7 @@ fun MetaDetailsScreen( onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onOpenMeta = onOpenMeta, onCastClick = onCastClick, + onCompanyClick = onCompanyClick, ) Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) @@ -755,6 +757,7 @@ private fun ConfiguredMetaSections( onEpisodeLongPress: (MetaVideo) -> Unit, onOpenMeta: ((MetaPreview) -> Unit)?, onCastClick: ((MetaPerson) -> Unit)?, + onCompanyClick: ((MetaCompany, String) -> Unit)?, ) { settings.items .filter { it.enabled } @@ -777,7 +780,10 @@ private fun ConfiguredMetaSections( MetaScreenSectionKey.PRODUCTION -> { if (hasProductionSection) { - DetailProductionSection(meta = meta) + DetailProductionSection( + meta = meta, + onCompanyClick = onCompanyClick, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index d4e06d9d3..72dfc5dab 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -378,7 +378,7 @@ private fun PersonDetailSkeleton(personName: String) { Row( modifier = Modifier .fillMaxWidth() - .padding(start = 0.dp), + .padding(horizontal = 20.dp), horizontalArrangement = Arrangement.spacedBy(10.dp), ) { repeat(4) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt new file mode 100644 index 000000000..a6a4e0723 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -0,0 +1,396 @@ +package com.nuvio.app.features.details + +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.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +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 coil3.compose.AsyncImage +import com.nuvio.app.features.details.components.DetailPosterRailSection +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.tmdb.TmdbEntityBrowseData +import com.nuvio.app.features.tmdb.TmdbEntityKind +import com.nuvio.app.features.tmdb.TmdbEntityMediaType +import com.nuvio.app.features.tmdb.TmdbEntityRailType +import com.nuvio.app.features.tmdb.TmdbMetadataService + +private sealed interface EntityBrowseUiState { + data object Loading : EntityBrowseUiState + data class Error(val message: String) : EntityBrowseUiState + data class Success(val data: TmdbEntityBrowseData) : EntityBrowseUiState +} + +@Composable +fun TmdbEntityBrowseScreen( + entityKind: TmdbEntityKind, + entityId: Int, + entityName: String, + sourceType: String, + onBack: () -> Unit, + onOpenMeta: (MetaPreview) -> Unit, + modifier: Modifier = Modifier, +) { + var uiState by remember(entityKind, entityId) { + mutableStateOf(EntityBrowseUiState.Loading) + } + + LaunchedEffect(entityKind, entityId) { + uiState = EntityBrowseUiState.Loading + val data = TmdbMetadataService.fetchEntityBrowse( + entityKind = entityKind, + entityId = entityId, + sourceType = sourceType, + fallbackName = entityName, + ) + uiState = if (data != null) { + EntityBrowseUiState.Success(data) + } else { + EntityBrowseUiState.Error("Could not load $entityName") + } + } + + Box(modifier = modifier.fillMaxSize()) { + Crossfade( + targetState = uiState, + label = "EntityBrowseCrossfade", + ) { state -> + when (state) { + is EntityBrowseUiState.Loading -> EntityBrowseSkeleton() + is EntityBrowseUiState.Error -> EntityBrowseError( + message = state.message, + onRetry = { uiState = EntityBrowseUiState.Loading }, + ) + is EntityBrowseUiState.Success -> EntityBrowseContent( + data = state.data, + sourceType = sourceType, + onOpenMeta = onOpenMeta, + ) + } + } + + IconButton( + onClick = onBack, + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(start = 4.dp, top = 4.dp) + .align(Alignment.TopStart), + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "Back", + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} + +@Composable +private fun EntityBrowseContent( + data: TmdbEntityBrowseData, + sourceType: String, + onOpenMeta: (MetaPreview) -> Unit, +) { + val backgroundUrl = remember(data.rails, sourceType) { + val preferredMediaType = if (sourceType.trim().equals("movie", ignoreCase = true)) { + TmdbEntityMediaType.MOVIE + } else { + TmdbEntityMediaType.TV + } + data.rails.firstOrNull { it.mediaType == preferredMediaType } + ?.items?.firstOrNull()?.poster + ?: data.rails.firstOrNull()?.items?.firstOrNull()?.poster + } + + Box(modifier = Modifier.fillMaxSize()) { + if (backgroundUrl != null) { + AsyncImage( + model = backgroundUrl, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + alpha = 0.10f, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to MaterialTheme.colorScheme.background.copy(alpha = 0.7f), + 0.3f to MaterialTheme.colorScheme.background.copy(alpha = 0.95f), + 1f to MaterialTheme.colorScheme.background, + ), + ), + ) + + if (data.rails.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No titles found", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 56.dp), + ) { + EntityHeroSection( + header = data.header, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) + + data.rails.forEach { rail -> + val railTitle = remember(rail.mediaType, rail.railType) { + val mediaLabel = when (rail.mediaType) { + TmdbEntityMediaType.MOVIE -> "Movies" + TmdbEntityMediaType.TV -> "Series" + } + val railLabel = when (rail.railType) { + TmdbEntityRailType.POPULAR -> "Popular" + TmdbEntityRailType.TOP_RATED -> "Top Rated" + TmdbEntityRailType.RECENT -> "Recent" + } + "$mediaLabel • $railLabel" + } + + DetailPosterRailSection( + title = railTitle, + items = rail.items, + watchedKeys = emptySet(), + headerHorizontalPadding = 20.dp, + onPosterClick = onOpenMeta, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } +} + +@Composable +private fun EntityHeroSection( + header: com.nuvio.app.features.tmdb.TmdbEntityHeader, + modifier: Modifier = Modifier, +) { + val hasLogo = !header.logo.isNullOrBlank() + + Column(modifier = modifier.padding(horizontal = 20.dp)) { + Text( + text = when (header.kind) { + TmdbEntityKind.COMPANY -> "Production Company" + TmdbEntityKind.NETWORK -> "Network" + }, + style = MaterialTheme.typography.labelLarge.copy( + fontWeight = FontWeight.Medium, + letterSpacing = 0.4.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + if (hasLogo) { + Box( + modifier = Modifier + .height(60.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White) + .padding(horizontal = 16.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = header.logo, + contentDescription = header.name, + modifier = Modifier.height(44.dp), + contentScale = ContentScale.Fit, + ) + } + Spacer(modifier = Modifier.height(12.dp)) + } + + Text( + text = header.name, + style = MaterialTheme.typography.headlineLarge.copy( + fontWeight = FontWeight.ExtraBold, + letterSpacing = (-0.5).sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + val metaLine = listOfNotNull( + header.originCountry?.takeIf { it.isNotBlank() }, + header.secondaryLabel?.takeIf { it.isNotBlank() }, + ).joinToString(" • ") + if (metaLine.isNotBlank()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = metaLine, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + header.description?.takeIf { it.isNotBlank() }?.let { description -> + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +private fun EntityBrowseSkeleton() { + Column( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 56.dp), + ) { + Column(modifier = Modifier.padding(horizontal = 20.dp)) { + Box( + modifier = Modifier + .width(120.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + ) + Spacer(modifier = Modifier.height(12.dp)) + Box( + modifier = Modifier + .width(200.dp) + .height(28.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)), + ) + Spacer(modifier = Modifier.height(10.dp)) + Box( + modifier = Modifier + .width(140.dp) + .height(14.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + ) + } + + Spacer(modifier = Modifier.height(28.dp)) + + repeat(3) { + Column(modifier = Modifier.padding(bottom = 20.dp)) { + Box( + modifier = Modifier + .padding(horizontal = 20.dp) + .width(160.dp) + .height(16.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) + Spacer(modifier = Modifier.height(10.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + repeat(4) { + Box( + modifier = Modifier + .width(110.dp) + .height(163.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)), + ) + } + } + } + } + } +} + +@Composable +private fun EntityBrowseError( + message: String, + onRetry: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(16.dp)) + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) { + Text("Retry") + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt index f444aaf3c..f182d7368 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt @@ -28,7 +28,7 @@ fun DetailPosterRailSection( entries = items, modifier = modifier, headerHorizontalPadding = headerHorizontalPadding, - rowContentPadding = PaddingValues(0.dp), + rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding), showHeaderAccent = false, key = { item -> item.stableKey() }, ) { item -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt index fe77bd3ae..b9155053b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.details.components import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -30,8 +31,10 @@ import com.nuvio.app.features.details.MetaDetails fun DetailProductionSection( meta: MetaDetails, modifier: Modifier = Modifier, + onCompanyClick: ((MetaCompany, String) -> Unit)? = null, ) { val isSeriesLike = meta.type == "series" || meta.videos.any { it.season != null || it.episode != null } + val isNetworkSource = isSeriesLike && meta.networks.isNotEmpty() val sourceItems = if (isSeriesLike) { meta.networks.ifEmpty { meta.productionCompanies } } else { @@ -39,6 +42,8 @@ fun DetailProductionSection( } if (sourceItems.isEmpty()) return + val entityKind = if (isNetworkSource) "network" else "company" + val displayItems = if (isSeriesLike) { sourceItems.take(6) } else { @@ -78,6 +83,9 @@ fun DetailProductionSection( chipHeight = chipHeight, logoWidth = logoWidth, logoHeight = logoHeight, + onClick = if (onCompanyClick != null && item.tmdbId != null) { + { onCompanyClick(item, entityKind) } + } else null, ) } } @@ -91,11 +99,15 @@ private fun ProductionChip( chipHeight: androidx.compose.ui.unit.Dp, logoWidth: androidx.compose.ui.unit.Dp, logoHeight: androidx.compose.ui.unit.Dp, + onClick: (() -> Unit)? = null, ) { Box( modifier = Modifier .clip(RoundedCornerShape(12.dp)) .background(color = ProductionChipBackground) + .then( + if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier + ) .padding(horizontal = 12.dp, vertical = 8.dp) .height(chipHeight), contentAlignment = Alignment.Center, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index dfb5b7312..7d4a1600b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -29,6 +29,9 @@ object TmdbMetadataService { private val collectionCache = mutableMapOf>>() private val trailerCache = mutableMapOf>() private val personCache = mutableMapOf() + private val entityBrowseCache = mutableMapOf() + private val entityHeaderCache = mutableMapOf() + private val entityRailCache = mutableMapOf>() suspend fun fetchPersonDetail( personId: Int, @@ -189,6 +192,259 @@ object TmdbMetadataService { } } + suspend fun fetchEntityBrowse( + entityKind: TmdbEntityKind, + entityId: Int, + sourceType: String, + fallbackName: String? = null, + ): TmdbEntityBrowseData? = withContext(Dispatchers.Default) { + val settings = TmdbSettingsRepository.snapshot() + if (!settings.enabled || !settings.hasApiKey) return@withContext null + val language = normalizeTmdbLanguage(settings.language) + val normalizedSourceType = normalizeEntitySourceType(sourceType) + val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$language" + entityBrowseCache[cacheKey]?.let { return@withContext it } + + val header = fetchEntityHeader( + entityKind = entityKind, + entityId = entityId, + fallbackName = fallbackName, + language = language, + ) + + val rails = buildEntityMediaOrder(entityKind, normalizedSourceType) + .flatMap { mediaType -> + TmdbEntityRailType.entries.mapNotNull { railType -> + val pageResult = fetchEntityRailPage( + entityKind = entityKind, + entityId = entityId, + mediaType = mediaType, + railType = railType, + language = language, + page = 1, + ) + if (pageResult.items.isEmpty()) { + null + } else { + TmdbEntityRail( + mediaType = mediaType, + railType = railType, + items = pageResult.items, + currentPage = 1, + hasMore = pageResult.hasMore, + ) + } + } + } + + 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 + } + + suspend fun fetchEntityRailPage( + entityKind: TmdbEntityKind, + entityId: Int, + mediaType: TmdbEntityMediaType, + railType: TmdbEntityRailType, + language: String, + page: Int, + ): TmdbEntityRailPageResult { + if (entityKind == TmdbEntityKind.NETWORK && mediaType == TmdbEntityMediaType.MOVIE) { + return TmdbEntityRailPageResult(items = emptyList(), hasMore = false) + } + + val cacheKey = "${entityKind.routeValue}:$entityId:${mediaType.value}:${railType.value}:$language:page:$page" + entityRailCache[cacheKey]?.let { cached -> + return TmdbEntityRailPageResult(items = cached, hasMore = cached.isNotEmpty()) + } + + val voteCountFloor = if (railType == TmdbEntityRailType.TOP_RATED) ENTITY_TOP_RATED_VOTE_FLOOR else null + + val result = try { + val sortBy = when (mediaType) { + TmdbEntityMediaType.MOVIE -> when (railType) { + TmdbEntityRailType.POPULAR -> "popularity.desc" + TmdbEntityRailType.TOP_RATED -> "vote_average.desc" + TmdbEntityRailType.RECENT -> "primary_release_date.desc" + } + TmdbEntityMediaType.TV -> when (railType) { + TmdbEntityRailType.POPULAR -> "popularity.desc" + TmdbEntityRailType.TOP_RATED -> "vote_average.desc" + TmdbEntityRailType.RECENT -> "first_air_date.desc" + } + } + + val queryParams = buildMap { + put("language", language) + put("page", page.toString()) + put("sort_by", sortBy) + when (mediaType) { + TmdbEntityMediaType.MOVIE -> { + put("with_companies", entityId.toString()) + } + TmdbEntityMediaType.TV -> { + if (entityKind == TmdbEntityKind.COMPANY) put("with_companies", entityId.toString()) + if (entityKind == TmdbEntityKind.NETWORK) put("with_networks", entityId.toString()) + } + } + if (voteCountFloor != null) put("vote_count.gte", voteCountFloor.toString()) + } + + val endpoint = when (mediaType) { + TmdbEntityMediaType.MOVIE -> "discover/movie" + TmdbEntityMediaType.TV -> "discover/tv" + } + + val response = fetch(endpoint = endpoint, query = queryParams) + val results = response?.results.orEmpty() + val totalPages = response?.totalPages ?: page + + val mappedItems = results + .filter { it.id > 0 } + .mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) } + .take(ENTITY_RAIL_MAX_ITEMS) + + TmdbEntityRailPageResult( + items = mappedItems, + hasMore = page < totalPages && mappedItems.isNotEmpty(), + ) + } catch (e: Exception) { + log.w(e) { "Failed to fetch entity rail ${railType.value}/${mediaType.value} for $entityId" } + TmdbEntityRailPageResult(items = emptyList(), hasMore = false) + } + + if (result.items.isNotEmpty()) { + entityRailCache[cacheKey] = result.items + } + return result + } + + 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 = fetch(endpoint = "company/$entityId") + body?.let { + TmdbEntityHeader( + id = it.id, + kind = entityKind, + name = it.name?.takeIf { n -> n.isNotBlank() } + ?: fallbackName?.takeIf { n -> n.isNotBlank() } + ?: "Unknown", + logo = buildImageUrl(it.logoPath, "w500"), + originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, + secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, + description = it.description?.takeIf { d -> d.isNotBlank() }, + ) + } + } + TmdbEntityKind.NETWORK -> { + val body = fetch(endpoint = "network/$entityId") + body?.let { + TmdbEntityHeader( + id = it.id, + kind = entityKind, + name = it.name?.takeIf { n -> n.isNotBlank() } + ?: fallbackName?.takeIf { n -> n.isNotBlank() } + ?: "Unknown", + logo = buildImageUrl(it.logoPath, "w500"), + originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, + secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, + description = null, + ) + } + } + } + } catch (e: Exception) { + log.w(e) { "Failed to fetch ${entityKind.routeValue} header for $entityId" } + 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 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, "w500") + ?: buildImageUrl(result.backdropPath, "w780") + ?: return null + 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) "series" else "movie", + name = title, + poster = poster, + description = result.overview?.takeIf { it.isNotBlank() }, + releaseInfo = releaseInfo, + ) + } + + private fun buildEntityMediaOrder( + entityKind: TmdbEntityKind, + sourceType: String, + ): List { + if (entityKind == TmdbEntityKind.NETWORK) { + return listOf(TmdbEntityMediaType.TV) + } + return when (sourceType) { + "movie" -> listOf(TmdbEntityMediaType.MOVIE, TmdbEntityMediaType.TV) + else -> listOf(TmdbEntityMediaType.TV, TmdbEntityMediaType.MOVIE) + } + } + + private fun normalizeEntitySourceType(sourceType: String): String { + return when (sourceType.trim().lowercase()) { + "movie" -> "movie" + "tv", "series", "show" -> "tv" + else -> "tv" + } + } + suspend fun enrichMeta( meta: MetaDetails, fallbackItemId: String, @@ -1278,3 +1534,105 @@ private data class TmdbPersonCreditCrew( @SerialName("first_air_date") val firstAirDate: String? = null, @SerialName("vote_average") val voteAverage: Double? = null, ) + +// ─── Entity Browse (Company / Network) Models ─── + +private const val ENTITY_RAIL_MAX_ITEMS = 20 +private const val ENTITY_TOP_RATED_VOTE_FLOOR = 200 + +enum class TmdbEntityKind(val routeValue: String) { + COMPANY("company"), + NETWORK("network"); + + companion object { + fun fromRouteValue(value: String): TmdbEntityKind = when (value.trim().lowercase()) { + "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, + val currentPage: Int = 1, + val hasMore: Boolean = false, + val isLoading: Boolean = false, +) + +data class TmdbEntityBrowseData( + val header: TmdbEntityHeader, + val rails: List, +) + +data class TmdbEntityRailPageResult( + val items: List, + val hasMore: Boolean, +) + +@Serializable +private data class TmdbCompanyDetailsResponse( + val id: Int, + val name: String? = null, + val description: String? = null, + val headquarters: String? = null, + val homepage: String? = null, + @SerialName("logo_path") val logoPath: String? = null, + @SerialName("origin_country") val originCountry: String? = null, +) + +@Serializable +private data class TmdbNetworkDetailsResponse( + val id: Int, + val name: String? = null, + val headquarters: String? = null, + val homepage: String? = null, + @SerialName("logo_path") val logoPath: String? = null, + @SerialName("origin_country") val originCountry: String? = null, +) + +@Serializable +private data class TmdbDiscoverResponse( + val page: Int? = null, + val results: List = emptyList(), + @SerialName("total_pages") val totalPages: Int? = null, + @SerialName("total_results") val totalResults: Int? = null, +) + +@Serializable +private data class TmdbDiscoverResult( + val id: Int, + val title: String? = null, + val name: String? = null, + @SerialName("original_title") val originalTitle: String? = null, + @SerialName("original_name") val originalName: String? = null, + @SerialName("poster_path") val posterPath: String? = null, + @SerialName("backdrop_path") val backdropPath: String? = null, + val overview: String? = null, + @SerialName("release_date") val releaseDate: String? = null, + @SerialName("first_air_date") val firstAirDate: String? = null, + @SerialName("vote_average") val voteAverage: Double? = null, + @SerialName("vote_count") val voteCount: Int? = null, +)