From b82d9caced77fdba1e86a1ae4b540579e769dc1f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:40:38 +0530 Subject: [PATCH] feat: add moreLikeThis and collection features to MetaDetails, enhancing recommendations and collections display in detail screens --- .../commonMain/kotlin/com/nuvio/app/App.kt | 22 +++ .../nuvio/app/core/ui/NuvioShelfComponents.kt | 34 ++-- .../app/features/details/MetaDetailsModels.kt | 4 + .../app/features/details/MetaDetailsScreen.kt | 27 +++ .../components/DetailPosterRailSection.kt | 42 +++++ .../app/features/settings/TmdbSettingsPage.kt | 4 +- .../app/features/tmdb/TmdbMetadataService.kt | 177 +++++++++++++++++- 7 files changed, 294 insertions(+), 16 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 047132e5..b94ef2c6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -96,6 +96,7 @@ import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen +import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -480,6 +481,27 @@ private fun MainAppContent( navController.popBackStack() }, onPlay = onPlay, + 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(), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index 0f1ec4c4..8cb7d3b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -52,6 +52,7 @@ fun NuvioShelfSection( headerHorizontalPadding: Dp = 0.dp, rowContentPadding: PaddingValues = PaddingValues(0.dp), itemSpacing: Dp = 10.dp, + showHeaderAccent: Boolean = true, onViewAllClick: (() -> Unit)? = null, viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default, key: ((T) -> Any)? = null, @@ -64,6 +65,7 @@ fun NuvioShelfSection( NuvioShelfSectionHeader( title = title, modifier = Modifier.padding(horizontal = headerHorizontalPadding), + showAccent = showHeaderAccent, onViewAllClick = onViewAllClick, viewAllPillSize = viewAllPillSize, ) @@ -99,7 +101,7 @@ fun NuvioPosterCard( onLongClick: (() -> Unit)? = null, ) { Column( - modifier = modifier.width(110.dp), + modifier = modifier.width(shape.cardWidth), verticalArrangement = Arrangement.spacedBy(6.dp), ) { Box( @@ -161,6 +163,7 @@ fun NuvioPosterCard( private fun NuvioShelfSectionHeader( title: String, modifier: Modifier = Modifier, + showAccent: Boolean = true, onViewAllClick: (() -> Unit)? = null, viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default, ) { @@ -179,16 +182,18 @@ private fun NuvioShelfSectionHeader( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Box( - modifier = Modifier - .padding(top = 6.dp) - .width(60.dp) - .height(4.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(999.dp), - ), - ) + if (showAccent) { + Box( + modifier = Modifier + .padding(top = 6.dp) + .width(60.dp) + .height(4.dp) + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(999.dp), + ), + ) + } } if (onViewAllClick != null) { NuvioViewAllPill( @@ -245,6 +250,13 @@ private val NuvioPosterShape.aspectRatio: Float NuvioPosterShape.Landscape -> 1.77f } +private val NuvioPosterShape.cardWidth: Dp + get() = when (this) { + NuvioPosterShape.Poster -> 110.dp + NuvioPosterShape.Square -> 110.dp + NuvioPosterShape.Landscape -> 180.dp + } + @OptIn(ExperimentalFoundationApi::class) internal fun Modifier.posterCardClickable( onClick: (() -> Unit)?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index 3a92f977..1fe10786 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.details +import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.streams.StreamItem data class MetaDetails( @@ -26,6 +27,9 @@ data class MetaDetails( val language: String? = null, val website: String? = null, val hasScheduledVideos: Boolean = false, + val moreLikeThis: List = emptyList(), + val collectionName: String? = null, + val collectionItems: List = emptyList(), val links: List = emptyList(), val videos: List = emptyList(), ) 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 d7ac430c..2a8a09d0 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 @@ -42,9 +42,11 @@ import com.nuvio.app.features.details.components.DetailCastSection import com.nuvio.app.features.details.components.DetailFloatingHeader import com.nuvio.app.features.details.components.DetailHero import com.nuvio.app.features.details.components.DetailMetaInfo +import com.nuvio.app.features.details.components.DetailPosterRailSection import com.nuvio.app.features.details.components.DetailProductionSection import com.nuvio.app.features.details.components.DetailSeriesContent import com.nuvio.app.features.details.components.EpisodeWatchedActionSheet +import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.watched.WatchedRepository @@ -62,6 +64,7 @@ fun MetaDetailsScreen( id: String, onBack: () -> Unit, 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, modifier: Modifier = Modifier, ) { val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() @@ -175,6 +178,12 @@ fun MetaDetailsScreen( meta.country != null || meta.language != null } + val hasCollectionSection = remember(meta) { + meta.collectionName != null && meta.collectionItems.isNotEmpty() + } + val hasMoreLikeThisSection = remember(meta) { + meta.moreLikeThis.isNotEmpty() + } val playButtonLabel = remember(movieProgress, seriesAction, meta.type, hasEpisodes) { when { (meta.type == "series" || hasEpisodes) && seriesAction != null -> @@ -327,6 +336,24 @@ fun MetaDetailsScreen( DetailAdditionalInfoSection(meta = meta) } + if (!hasEpisodes && hasCollectionSection) { + DetailPosterRailSection( + title = meta.collectionName.orEmpty(), + items = meta.collectionItems, + watchedKeys = watchedUiState.watchedKeys, + onPosterClick = onOpenMeta, + ) + } + + if (hasMoreLikeThisSection) { + DetailPosterRailSection( + title = "More Like This", + items = meta.moreLikeThis, + watchedKeys = watchedUiState.watchedKeys, + onPosterClick = onOpenMeta, + ) + } + Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) } } 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 new file mode 100644 index 00000000..317e2eb3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt @@ -0,0 +1,42 @@ +package com.nuvio.app.features.details.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioShelfSection +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.components.HomePosterCard +import com.nuvio.app.features.home.stableKey +import com.nuvio.app.features.watching.application.WatchingState + +@Composable +fun DetailPosterRailSection( + title: String, + items: List, + watchedKeys: Set, + modifier: Modifier = Modifier, + onPosterClick: ((MetaPreview) -> Unit)? = null, + onPosterLongClick: ((MetaPreview) -> Unit)? = null, +) { + if (items.isEmpty()) return + + NuvioShelfSection( + title = title, + entries = items, + modifier = modifier, + rowContentPadding = PaddingValues(0.dp), + showHeaderAccent = false, + key = { item -> item.stableKey() }, + ) { item -> + HomePosterCard( + item = item, + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + ), + onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index c8bd061a..795fa364 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -130,7 +130,7 @@ internal fun LazyListScope.tmdbSettingsContent( TmdbToggleRow( isTablet = isTablet, title = "More like this", - description = "Reserved for the upcoming TMDB recommendation rail port.", + description = "Show TMDB recommendations at the bottom of detail pages.", checked = settings.useMoreLikeThis, enabled = settings.enabled, onCheckedChange = TmdbSettingsRepository::setUseMoreLikeThis, @@ -139,7 +139,7 @@ internal fun LazyListScope.tmdbSettingsContent( TmdbToggleRow( isTablet = isTablet, title = "Collections", - description = "Reserved for the upcoming TMDB collection rail port.", + description = "Show franchise and collection rails for movies when TMDB provides them.", checked = settings.useCollections, enabled = settings.enabled, onCheckedChange = TmdbSettingsRepository::setUseCollections, 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 c7cc0816..c79f8808 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 @@ -6,6 +6,8 @@ import com.nuvio.app.features.details.MetaCompany import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -21,6 +23,8 @@ object TmdbMetadataService { private val enrichmentCache = mutableMapOf() private val episodeCache = mutableMapOf, TmdbEpisodeEnrichment>>() + private val moreLikeThisCache = mutableMapOf>() + private val collectionCache = mutableMapOf>>() suspend fun enrichMeta( meta: MetaDetails, @@ -41,6 +45,7 @@ object TmdbMetadataService { tmdbId = tmdbId, mediaType = tmdbType, language = settings.language, + settings = settings, ) } val episodeDeferred = if (needsEpisodes) { @@ -142,6 +147,17 @@ object TmdbMetadataService { ) } + if (enrichment != null && settings.useMoreLikeThis) { + updated = updated.copy(moreLikeThis = enrichment.moreLikeThis) + } + + if (enrichment != null && settings.useCollections) { + updated = updated.copy( + collectionName = enrichment.collectionName, + collectionItems = enrichment.collectionItems, + ) + } + return updated } @@ -149,6 +165,7 @@ object TmdbMetadataService { tmdbId: String, mediaType: String, language: String, + settings: TmdbSettings, ): TmdbEnrichment? = withContext(Dispatchers.Default) { val normalizedLanguage = normalizeTmdbLanguage(language) val cacheKey = "$tmdbId:$mediaType:$normalizedLanguage" @@ -191,11 +208,22 @@ object TmdbMetadataService { )?.results.orEmpty().selectMovieAgeRating(normalizedLanguage) } } + val moreLikeThis = async { + if (settings.useMoreLikeThis && (mediaType == "movie" || mediaType == "tv")) { + fetchMoreLikeThis( + tmdbId = numericId, + mediaType = mediaType, + language = normalizedLanguage, + ) + } else { + emptyList() + } + } Quadruple( first = details.await(), second = credits.await(), third = images.await(), - fourth = ageRating.await(), + fourth = Pair(ageRating.await(), moreLikeThis.await()), ) } @@ -223,7 +251,7 @@ object TmdbMetadataService { releaseInfo = releaseInfo, rating = details.voteAverage, runtimeMinutes = details.runtime ?: details.episodeRunTime.firstOrNull(), - ageRating = response.fourth, + ageRating = response.fourth.first, status = details.status?.trim()?.takeIf(String::isNotBlank), countries = details.productionCountries .mapNotNull { it.iso31661?.trim()?.takeIf(String::isNotBlank) } @@ -231,6 +259,16 @@ object TmdbMetadataService { language = details.originalLanguage?.trim()?.takeIf(String::isNotBlank), productionCompanies = details.productionCompanies.mapNotNull { it.toMetaCompany() }, networks = details.networks.mapNotNull { it.toMetaCompany() }, + collectionName = details.belongsToCollection?.name?.trim()?.takeIf(String::isNotBlank), + collectionItems = if (settings.useCollections && details.belongsToCollection?.id != null) { + fetchCollection( + collectionId = details.belongsToCollection.id, + language = normalizedLanguage, + ).second + } else { + emptyList() + }, + moreLikeThis = response.fourth.second, ) if (!enrichment.hasContent()) return@withContext null @@ -293,6 +331,89 @@ object TmdbMetadataService { log.w { "TMDB request failed for $endpoint: ${error.message}" } }.getOrNull() } + + private suspend fun fetchMoreLikeThis( + tmdbId: Int, + mediaType: String, + language: String, + ): List { + val cacheKey = "$tmdbId:$mediaType:$language:recommendations" + moreLikeThisCache[cacheKey]?.let { return it } + + val response = fetch( + endpoint = "$mediaType/$tmdbId/recommendations", + query = mapOf("language" to language), + ) ?: return emptyList() + + val items = response.results + .filter { it.id > 0 } + .mapNotNull { recommendation -> + val inferredType = when (recommendation.mediaType?.lowercase()) { + "tv" -> "series" + "movie" -> "movie" + else -> if (mediaType == "tv") "series" else "movie" + } + val title = recommendation.title + ?.trim() + ?.takeIf(String::isNotBlank) + ?: recommendation.name?.trim()?.takeIf(String::isNotBlank) + ?: recommendation.originalTitle?.trim()?.takeIf(String::isNotBlank) + ?: recommendation.originalName?.trim()?.takeIf(String::isNotBlank) + ?: return@mapNotNull null + + MetaPreview( + id = "tmdb:${recommendation.id}", + type = inferredType, + name = title, + poster = buildImageUrl(recommendation.posterPath, "w500") + ?: buildImageUrl(recommendation.backdropPath, "w780"), + banner = buildImageUrl(recommendation.backdropPath, "w1280"), + posterShape = PosterShape.Poster, + description = recommendation.overview?.trim()?.takeIf(String::isNotBlank), + releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4), + imdbRating = recommendation.voteAverage?.formatRating(), + ) + } + .take(12) + + moreLikeThisCache[cacheKey] = items + return items + } + + private suspend fun fetchCollection( + collectionId: Int, + language: String, + ): Pair> { + val cacheKey = "$collectionId:$language:collection" + collectionCache[cacheKey]?.let { return it } + + val response = fetch( + endpoint = "collection/$collectionId", + query = mapOf("language" to language), + ) ?: return null to emptyList() + + val items = response.parts + .sortedBy { it.releaseDate ?: "9999" } + .mapNotNull { part -> + val title = part.title?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${part.id}", + type = "movie", + name = title, + poster = buildImageUrl(part.backdropPath, "w780") + ?: buildImageUrl(part.posterPath, "w500"), + banner = buildImageUrl(part.backdropPath, "w1280"), + posterShape = PosterShape.Landscape, + description = part.overview?.trim()?.takeIf(String::isNotBlank), + releaseInfo = part.releaseDate?.take(4), + imdbRating = part.voteAverage?.formatRating(), + ) + } + + val result = response.name?.trim()?.takeIf(String::isNotBlank) to items + collectionCache[cacheKey] = result + return result + } } internal data class TmdbEnrichment( @@ -314,6 +435,9 @@ internal data class TmdbEnrichment( val language: String?, val productionCompanies: List, val networks: List, + val collectionName: String?, + val collectionItems: List, + val moreLikeThis: List, ) { fun hasContent(): Boolean = localizedTitle != null || @@ -333,7 +457,9 @@ internal data class TmdbEnrichment( countries.isNotEmpty() || language != null || productionCompanies.isNotEmpty() || - networks.isNotEmpty() + networks.isNotEmpty() || + collectionItems.isNotEmpty() || + moreLikeThis.isNotEmpty() } internal data class TmdbEpisodeEnrichment( @@ -585,6 +711,7 @@ private data class TmdbDetailsResponse( val genres: List = emptyList(), @SerialName("production_companies") val productionCompanies: List = emptyList(), val networks: List = emptyList(), + @SerialName("belongs_to_collection") val belongsToCollection: TmdbCollectionRef? = null, ) @Serializable @@ -672,6 +799,50 @@ private data class TmdbCompany( @SerialName("logo_path") val logoPath: String? = null, ) +@Serializable +private data class TmdbCollectionRef( + val id: Int? = null, + val name: String? = null, +) + +@Serializable +private data class TmdbRecommendationResponse( + val results: List = emptyList(), +) + +@Serializable +private data class TmdbRecommendationItem( + 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("media_type") val mediaType: String? = null, +) + +@Serializable +private data class TmdbCollectionResponse( + val name: String? = null, + val parts: List = emptyList(), +) + +@Serializable +private data class TmdbCollectionPart( + val id: Int, + val title: 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("vote_average") val voteAverage: Double? = null, +) + @Serializable private data class TmdbSeasonDetailsResponse( val episodes: List = emptyList(),