feat: add moreLikeThis and collection features to MetaDetails, enhancing recommendations and collections display in detail screens

This commit is contained in:
tapframe 2026-03-30 20:40:38 +05:30
parent a4a4f3ced4
commit b82d9caced
7 changed files with 294 additions and 16 deletions

View file

@ -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(),
)
}

View file

@ -52,6 +52,7 @@ fun <T> 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 <T> 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)?,

View file

@ -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<MetaPreview> = emptyList(),
val collectionName: String? = null,
val collectionItems: List<MetaPreview> = emptyList(),
val links: List<MetaLink> = emptyList(),
val videos: List<MetaVideo> = emptyList(),
)

View file

@ -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))
}
}

View file

@ -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<MetaPreview>,
watchedKeys: Set<String>,
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) } },
)
}
}

View file

@ -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,

View file

@ -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<String, TmdbEnrichment>()
private val episodeCache = mutableMapOf<String, Map<Pair<Int, Int>, TmdbEpisodeEnrichment>>()
private val moreLikeThisCache = mutableMapOf<String, List<MetaPreview>>()
private val collectionCache = mutableMapOf<String, Pair<String?, List<MetaPreview>>>()
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<MetaPreview> {
val cacheKey = "$tmdbId:$mediaType:$language:recommendations"
moreLikeThisCache[cacheKey]?.let { return it }
val response = fetch<TmdbRecommendationResponse>(
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<String?, List<MetaPreview>> {
val cacheKey = "$collectionId:$language:collection"
collectionCache[cacheKey]?.let { return it }
val response = fetch<TmdbCollectionResponse>(
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<MetaCompany>,
val networks: List<MetaCompany>,
val collectionName: String?,
val collectionItems: List<MetaPreview>,
val moreLikeThis: List<MetaPreview>,
) {
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<TmdbNamedItem> = emptyList(),
@SerialName("production_companies") val productionCompanies: List<TmdbCompany> = emptyList(),
val networks: List<TmdbCompany> = 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<TmdbRecommendationItem> = 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<TmdbCollectionPart> = 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<TmdbEpisodeResponse> = emptyList(),