diff --git a/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt b/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt index 774f294b5..c69ff189e 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/api/TmdbApi.kt @@ -253,7 +253,16 @@ data class TmdbMovieDetails( val budget: Long = 0, val genres: List = emptyList(), val status: String? = null, - val adult: Boolean = false + val adult: Boolean = false, + @SerializedName("belongs_to_collection") val belongsToCollection: TmdbCollectionRef? = null +) + +/** Reference to a TMDB collection (franchise) returned inside movie/TV details. */ +data class TmdbCollectionRef( + val id: Int = 0, + val name: String? = null, + @SerializedName("poster_path") val posterPath: String? = null, + @SerializedName("backdrop_path") val backdropPath: String? = null ) data class TmdbTvDetails( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index f932d7d26..f04548d86 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -2395,7 +2395,33 @@ class MediaRepository @Inject constructor( detailsCache[cacheKey] = CacheEntry(item, System.currentTimeMillis()) return item } - + + /** + * Get the TMDB collection (franchise) reference for a movie. + * Calls /movie/{id} directly to access the `belongs_to_collection` field, + * which is discarded by getMovieDetails() → toMediaItem(). + * The response is cached by OkHttp, making the redundant call negligible. + */ + suspend fun getMovieCollectionRef(movieId: Int): com.arflix.tv.data.api.TmdbCollectionRef? { + return runCatching { + tmdbApi.getMovieDetails(movieId, apiKey, language = contentLanguage).belongsToCollection + }.getOrNull() + } + + /** + * Fetch all movies in a TMDB collection (franchise). + * Calls TMDB /collection/{id} and maps the parts array to Movie MediaItems. + * Used by the Details page to show franchise rows (e.g. "Cars Collection"). + */ + suspend fun getTmdbCollectionItems(collectionId: Int): List { + val response = runCatching { + tmdbApi.getTmdbCollection(collectionId, apiKey, language = contentLanguage) + }.getOrNull() ?: return emptyList() + return response.parts + .sortedBy { it.releaseDate.orEmpty() } + .map { it.toMediaItem(MediaType.MOVIE) } + } + /** * Get season episodes with Trakt watched status */ diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index c36ac2a01..f003f3114 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -385,6 +385,9 @@ fun AppNavigation( onNavigateToDetails = { type, id -> navController.navigate(Screen.Details.createRoute(type, id)) }, + onNavigateToCollection = { catalogId -> + navController.navigate(Screen.CollectionDetails.createRoute(catalogId)) + }, onNavigateToHome = { navigateHome() }, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt index ea5f8d77d..bf51ac1a0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/collections/CollectionDetailsScreen.kt @@ -57,7 +57,12 @@ import androidx.tv.foundation.lazy.grid.itemsIndexed import androidx.tv.foundation.lazy.grid.rememberTvLazyGridState import coil.compose.AsyncImage import com.arflix.tv.data.model.CatalogConfig +import com.arflix.tv.data.model.CatalogKind +import com.arflix.tv.data.model.CatalogSourceType import com.arflix.tv.data.model.CollectionGroupKind +import com.arflix.tv.data.model.CollectionSourceConfig +import com.arflix.tv.data.model.CollectionSourceKind +import com.arflix.tv.data.model.CollectionTileShape import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType import com.arflix.tv.data.repository.CatalogRepository @@ -118,6 +123,7 @@ class CollectionDetailsViewModel @Inject constructor( const val FIRST_PAGE = 8 const val PAGE_STEP = 12 const val BACKGROUND_PREFETCH_DELAY_MS = 350L + const val TMDB_COLLECTION_PREFIX = "tmdb_collection:" } fun load(catalogId: String) { @@ -130,6 +136,7 @@ class CollectionDetailsViewModel @Inject constructor( _uiState.value = CollectionDetailsUiState(isLoadingMovies = true, isLoadingSeries = true) val catalog = catalogRepository.getCatalogs().firstOrNull { it.id == catalogId } + ?: syntheticTmdbCollectionCatalog(catalogId) if (catalog == null) { _uiState.value = CollectionDetailsUiState( isLoadingMovies = false, @@ -167,6 +174,34 @@ class CollectionDetailsViewModel @Inject constructor( } } + private fun syntheticTmdbCollectionCatalog(catalogId: String): CatalogConfig? { + if (!catalogId.startsWith(TMDB_COLLECTION_PREFIX)) return null + + val payload = catalogId.removePrefix(TMDB_COLLECTION_PREFIX) + val collectionId = payload.substringBefore(":").toIntOrNull() ?: return null + val collectionName = payload.substringAfter(":", missingDelimiterValue = "") + .trim() + .takeIf { it.isNotBlank() } + ?: "Collection" + + return CatalogConfig( + id = catalogId, + title = collectionName, + sourceType = CatalogSourceType.PREINSTALLED, + isPreinstalled = true, + kind = CatalogKind.COLLECTION, + collectionGroup = CollectionGroupKind.FRANCHISE, + collectionTileShape = CollectionTileShape.POSTER, + collectionSources = listOf( + CollectionSourceConfig( + kind = CollectionSourceKind.TMDB_COLLECTION, + mediaType = "movie", + tmdbCollectionId = collectionId + ) + ) + ) + } + private suspend fun loadInitialTab(catalog: CatalogConfig, tab: CollectionTab) { val page = runCatching { mediaRepository.loadCollectionCatalogPage( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 2a550cc93..0634d7d8f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -186,6 +186,7 @@ fun DetailsScreen( currentProfile: com.arflix.tv.data.model.Profile? = null, onNavigateToPlayer: (MediaType, Int, Int?, Int?, String?, String?, String?, String?, Long?) -> Unit, onNavigateToDetails: (MediaType, Int) -> Unit, + onNavigateToCollection: (String) -> Unit = {}, onNavigateToHome: () -> Unit = {}, onNavigateToSearch: () -> Unit = {}, onNavigateToWatchlist: () -> Unit = {}, @@ -208,6 +209,7 @@ fun DetailsScreen( var castIndex by remember { mutableIntStateOf(0) } var reviewIndex by remember { mutableIntStateOf(0) } var similarIndex by remember { mutableIntStateOf(0) } + var collectionIndex by remember { mutableIntStateOf(0) } var suppressSelectUntilMs by remember { mutableLongStateOf(0L) } // Sidebar state @@ -390,14 +392,16 @@ fun DetailsScreen( FocusSection.CAST -> castIndex == 0 FocusSection.REVIEWS -> reviewIndex == 0 FocusSection.SIMILAR -> similarIndex == 0 + FocusSection.COLLECTION -> collectionIndex == 0 } if (atLeftmost) { true } else { handleLeft( - focusedSection, buttonIndex, episodeIndex, seasonIndex, castIndex, reviewIndex, similarIndex, + focusedSection, buttonIndex, episodeIndex, seasonIndex, castIndex, reviewIndex, similarIndex, collectionIndex, { buttonIndex = it }, { episodeIndex = it }, { seasonIndex = it }, - { castIndex = it }, { reviewIndex = it }, { similarIndex = it } + { castIndex = it }, { reviewIndex = it }, { similarIndex = it }, + { collectionIndex = it } ) } } @@ -410,9 +414,10 @@ fun DetailsScreen( true } else { handleRight( - focusedSection, buttonIndex, episodeIndex, seasonIndex, castIndex, reviewIndex, similarIndex, + focusedSection, buttonIndex, episodeIndex, seasonIndex, castIndex, reviewIndex, similarIndex, collectionIndex, uiState, { buttonIndex = it }, { episodeIndex = it }, { seasonIndex = it }, - { castIndex = it }, { reviewIndex = it }, { similarIndex = it } + { castIndex = it }, { reviewIndex = it }, { similarIndex = it }, + { collectionIndex = it } ) } } @@ -420,11 +425,13 @@ fun DetailsScreen( if (isSidebarFocused) { true } else { - // Navigation: BUTTONS -> SEASONS -> EPISODES -> CAST -> REVIEWS -> SIMILAR + // Navigation: BUTTONS -> SEASONS -> EPISODES -> CAST -> REVIEWS -> SIMILAR -> COLLECTION val isTV = mediaType == MediaType.TV val hasEpisodes = uiState.episodes.isNotEmpty() val hasCast = uiState.cast.isNotEmpty() val hasReviews = uiState.reviews.isNotEmpty() + val hasSimilar = uiState.similar.isNotEmpty() + val hasCollection = uiState.collectionItems.isNotEmpty() focusedSection = when (focusedSection) { FocusSection.BUTTONS -> { isSidebarFocused = true @@ -444,7 +451,17 @@ fun DetailsScreen( } else FocusSection.BUTTONS } FocusSection.REVIEWS -> if (hasCast) FocusSection.CAST else FocusSection.BUTTONS - FocusSection.SIMILAR -> if (hasReviews) FocusSection.REVIEWS else if (hasCast) FocusSection.CAST else FocusSection.BUTTONS + FocusSection.SIMILAR -> { + if (hasCollection) FocusSection.COLLECTION + else if (hasReviews) FocusSection.REVIEWS + else if (hasCast) FocusSection.CAST + else FocusSection.BUTTONS + } + FocusSection.COLLECTION -> { + if (hasReviews) FocusSection.REVIEWS + else if (hasCast) FocusSection.CAST + else FocusSection.BUTTONS + } } true } @@ -454,19 +471,21 @@ fun DetailsScreen( isSidebarFocused = false true } else { - // Navigation: BUTTONS -> SEASONS -> EPISODES -> CAST -> REVIEWS -> SIMILAR + // Navigation: BUTTONS -> SEASONS -> EPISODES -> CAST -> REVIEWS -> SIMILAR -> COLLECTION val isTV = mediaType == MediaType.TV val hasEpisodes = uiState.episodes.isNotEmpty() val hasSeasons = uiState.totalSeasons > 1 val hasCast = uiState.cast.isNotEmpty() val hasReviews = uiState.reviews.isNotEmpty() val hasSimilar = uiState.similar.isNotEmpty() + val hasCollection = uiState.collectionItems.isNotEmpty() focusedSection = when (focusedSection) { FocusSection.BUTTONS -> { if (isTV && hasSeasons) FocusSection.SEASONS else if (isTV && hasEpisodes) FocusSection.EPISODES else if (hasCast) FocusSection.CAST else if (hasReviews) FocusSection.REVIEWS + else if (hasCollection) FocusSection.COLLECTION else if (hasSimilar) FocusSection.SIMILAR else FocusSection.BUTTONS } @@ -474,24 +493,32 @@ fun DetailsScreen( if (hasEpisodes) FocusSection.EPISODES else if (hasCast) FocusSection.CAST else if (hasReviews) FocusSection.REVIEWS + else if (hasCollection) FocusSection.COLLECTION else if (hasSimilar) FocusSection.SIMILAR else FocusSection.SEASONS } FocusSection.EPISODES -> { if (hasCast) FocusSection.CAST else if (hasReviews) FocusSection.REVIEWS + else if (hasCollection) FocusSection.COLLECTION else if (hasSimilar) FocusSection.SIMILAR else FocusSection.EPISODES } FocusSection.CAST -> { if (hasReviews) FocusSection.REVIEWS + else if (hasCollection) FocusSection.COLLECTION else if (hasSimilar) FocusSection.SIMILAR else FocusSection.CAST } FocusSection.REVIEWS -> { - if (hasSimilar) FocusSection.SIMILAR else FocusSection.REVIEWS + if (hasCollection) FocusSection.COLLECTION + else if (hasSimilar) FocusSection.SIMILAR + else FocusSection.REVIEWS } - FocusSection.SIMILAR -> FocusSection.SIMILAR // Stay on similar (bottom) + FocusSection.COLLECTION -> { + if (hasSimilar) FocusSection.SIMILAR else FocusSection.COLLECTION + } + FocusSection.SIMILAR -> FocusSection.SIMILAR } true } @@ -604,6 +631,12 @@ fun DetailsScreen( onNavigateToDetails(similar.mediaType, similar.id) } } + FocusSection.COLLECTION -> { + val collectionItem = uiState.collectionItems.getOrNull(collectionIndex) + if (collectionItem != null) { + onNavigateToDetails(collectionItem.mediaType, collectionItem.id) + } + } } true } @@ -663,6 +696,9 @@ fun DetailsScreen( reviews = uiState.reviews, similar = uiState.similar, similarLogoUrls = uiState.similarLogoUrls, + collectionItems = uiState.collectionItems, + collectionName = uiState.collectionName, + collectionIndex = collectionIndex, focusedSection = focusedSection, buttonIndex = buttonIndex, episodeIndex = episodeIndex, @@ -725,6 +761,13 @@ fun DetailsScreen( } 3 -> viewModel.toggleWatched(episodeIndex) 4 -> viewModel.toggleWatchlist() + 5 -> { // View Collection — navigate to the CollectionDetailsScreen + val collectionId = uiState.collectionId + if (collectionId != null) { + val collectionName = uiState.collectionName.orEmpty() + onNavigateToCollection("tmdb_collection:$collectionId:$collectionName") + } + } } }, onSeasonClick = { idx -> @@ -758,6 +801,12 @@ fun DetailsScreen( if (sim != null) { onNavigateToDetails(sim.mediaType, sim.id) } + }, + onCollectionClick = { idx -> + val item = uiState.collectionItems.getOrNull(idx) + if (item != null) { + onNavigateToDetails(item.mediaType, item.id) + } } ) } @@ -919,7 +968,7 @@ fun DetailsScreen( } private enum class FocusSection { - BUTTONS, EPISODES, SEASONS, CAST, REVIEWS, SIMILAR + BUTTONS, EPISODES, SEASONS, CAST, REVIEWS, SIMILAR, COLLECTION } private data class PendingAutoPlayRequest( @@ -986,8 +1035,10 @@ private fun isPendingDebridStream(stream: com.arflix.tv.data.model.StreamSource) private fun handleLeft( section: FocusSection, buttonIdx: Int, episodeIdx: Int, seasonIdx: Int, castIdx: Int, reviewIdx: Int, similarIdx: Int, + collectionIdx: Int, setButton: (Int) -> Unit, setEpisode: (Int) -> Unit, setSeason: (Int) -> Unit, - setCast: (Int) -> Unit, setReview: (Int) -> Unit, setSimilar: (Int) -> Unit + setCast: (Int) -> Unit, setReview: (Int) -> Unit, setSimilar: (Int) -> Unit, + setCollection: (Int) -> Unit ): Boolean { when (section) { FocusSection.BUTTONS -> if (buttonIdx > 0) setButton(buttonIdx - 1) @@ -996,6 +1047,7 @@ private fun handleLeft( FocusSection.CAST -> if (castIdx > 0) setCast(castIdx - 1) FocusSection.REVIEWS -> if (reviewIdx > 0) setReview(reviewIdx - 1) FocusSection.SIMILAR -> if (similarIdx > 0) setSimilar(similarIdx - 1) + FocusSection.COLLECTION -> if (collectionIdx > 0) setCollection(collectionIdx - 1) } return true } @@ -1003,17 +1055,23 @@ private fun handleLeft( private fun handleRight( section: FocusSection, buttonIdx: Int, episodeIdx: Int, seasonIdx: Int, castIdx: Int, reviewIdx: Int, similarIdx: Int, + collectionIdx: Int, uiState: DetailsUiState, setButton: (Int) -> Unit, setEpisode: (Int) -> Unit, setSeason: (Int) -> Unit, - setCast: (Int) -> Unit, setReview: (Int) -> Unit, setSimilar: (Int) -> Unit + setCast: (Int) -> Unit, setReview: (Int) -> Unit, setSimilar: (Int) -> Unit, + setCollection: (Int) -> Unit ): Boolean { when (section) { - FocusSection.BUTTONS -> if (buttonIdx < 4) setButton(buttonIdx + 1) + FocusSection.BUTTONS -> { + val maxButton = if (uiState.collectionId != null) 5 else 4 + if (buttonIdx < maxButton) setButton(buttonIdx + 1) + } FocusSection.EPISODES -> if (episodeIdx < uiState.episodes.size - 1) setEpisode(episodeIdx + 1) FocusSection.SEASONS -> if (seasonIdx < uiState.totalSeasons - 1) setSeason(seasonIdx + 1) FocusSection.CAST -> if (castIdx < uiState.cast.size - 1) setCast(castIdx + 1) FocusSection.REVIEWS -> if (reviewIdx < uiState.reviews.size - 1) setReview(reviewIdx + 1) FocusSection.SIMILAR -> if (similarIdx < uiState.similar.size - 1) setSimilar(similarIdx + 1) + FocusSection.COLLECTION -> if (collectionIdx < uiState.collectionItems.size - 1) setCollection(collectionIdx + 1) } return true } @@ -1030,6 +1088,9 @@ private fun DetailsContent( reviews: List, similar: List, similarLogoUrls: Map, + collectionItems: List = emptyList(), + collectionName: String? = null, + collectionIndex: Int = 0, focusedSection: FocusSection, buttonIndex: Int, episodeIndex: Int, @@ -1054,7 +1115,8 @@ private fun DetailsContent( onEpisodeClick: (Int) -> Unit = {}, onCastClick: (Int) -> Unit = {}, spoilerBlurEnabled: Boolean = false, - onSimilarClick: (Int) -> Unit = {} + onSimilarClick: (Int) -> Unit = {}, + onCollectionClick: (Int) -> Unit = {} ) { val context = LocalContext.current val metadataLogoImageLoader = remember(context) { @@ -1494,6 +1556,43 @@ private fun DetailsContent( } } + // Collection items section — shown when this movie belongs to a TMDB collection + if (collectionItems.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Spacer(modifier = Modifier.height(24.dp)) + val displayName = collectionName ?: stringResource(R.string.more_like_this) + Text( + text = displayName, + style = ArvioSkin.typography.sectionTitle.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), + color = Color.White.copy(alpha = 0.9f) + ) + Spacer(modifier = Modifier.height(8.dp)) + } + LazyRow( + modifier = Modifier.arvioDpadFocusGroup(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + standardItemsIndexed( + collectionItems, + key = { index, m -> "mob_col_${m.mediaType.name}_${m.id}_$index" }, + contentType = { _, _ -> "collection" } + ) { index, mediaItem -> + SimilarMediaCard( + item = mediaItem, + logoImageUrl = null, + usePosterCards = usePosterCards, + isFocused = false, + onClick = { onCollectionClick(index) } + ) + } + } + } + // Reviews section if (reviews.isNotEmpty()) { Column( @@ -1863,6 +1962,18 @@ private fun DetailsContent( isActive = isInWatchlist ) } + + // "View Collection" button — only shown when this movie belongs to a TMDB collection + if (collectionItems.isNotEmpty()) { + Box(modifier = Modifier.clickable { onButtonClick(5) }) { + PremiumActionButton( + icon = Icons.Default.Star, + text = stringResource(R.string.view_collection), + isFocused = focusSectionForUi == FocusSection.BUTTONS && buttonIndex == 5, + isIconOnly = true + ) + } + } } } @@ -1909,6 +2020,9 @@ private fun DetailsTvRows( reviews: List, similar: List, similarLogoUrls: Map, + collectionItems: List = emptyList(), + collectionName: String? = null, + collectionIndex: Int = 0, focusedSection: FocusSection, focusSectionForUi: FocusSection?, episodeIndex: Int, @@ -1926,7 +2040,8 @@ private fun DetailsTvRows( onSeasonClick: (Int) -> Unit, onEpisodeClick: (Int) -> Unit, onCastClick: (Int) -> Unit, - onSimilarClick: (Int) -> Unit + onSimilarClick: (Int) -> Unit, + onCollectionClick: (Int) -> Unit = {} ) { val contentScrollState = rememberTvLazyListState() val detailsStackOffsetPx = remember { Animatable(0f) } @@ -1937,6 +2052,7 @@ private fun DetailsTvRows( val hasCast = cast.isNotEmpty() val hasReviews = reviews.isNotEmpty() val hasSimilar = similar.isNotEmpty() + val hasCollection = collectionItems.isNotEmpty() var idx = 0 val seasonsIdx = if (hasSeasons) idx.also { idx++ } else -1 @@ -1945,6 +2061,8 @@ private fun DetailsTvRows( val castIdx = if (hasCast) idx.also { idx++ } else -1 val reviewsSpacerIdx = if (hasReviews) idx.also { idx++ } else -1 val reviewsIdx = if (hasReviews) idx.also { idx++ } else -1 + val collectionSpacerIdx = if (hasCollection) idx.also { idx++ } else -1 + val collectionIdx = if (hasCollection) idx.also { idx++ } else -1 val similarSpacerIdx = if (hasSimilar) idx.also { idx++ } else -1 val similarIdx = if (hasSimilar) idx.also { idx++ } else -1 @@ -1959,6 +2077,7 @@ private fun DetailsTvRows( FocusSection.BUTTONS, FocusSection.EPISODES, FocusSection.SEASONS -> 0 FocusSection.CAST -> castSpacerIdx.takeIf { it >= 0 } ?: castIdx FocusSection.REVIEWS -> reviewsSpacerIdx.takeIf { it >= 0 } ?: reviewsIdx + FocusSection.COLLECTION -> collectionSpacerIdx.takeIf { it >= 0 } ?: collectionIdx FocusSection.SIMILAR -> similarSpacerIdx.takeIf { it >= 0 } ?: similarIdx } if (targetIndex < 0) return@LaunchedEffect @@ -2070,6 +2189,23 @@ private fun DetailsTvRows( } } + // Collection items row — shown when this movie belongs to a TMDB collection + if (collectionItems.isNotEmpty()) { + item { Spacer(modifier = Modifier.height(80.dp)) } + item { + DetailsCollectionRail( + collectionItems = collectionItems, + collectionName = collectionName, + collectionIndex = collectionIndex, + focusSectionForUi = focusSectionForUi, + usePosterCards = usePosterCards, + contentStartPadding = contentStartPadding, + contentOuterStartPadding = contentOuterStartPadding, + onCollectionClick = onCollectionClick + ) + } + } + if (similar.isNotEmpty()) { item { Spacer(modifier = Modifier.height(80.dp)) } item { @@ -2437,6 +2573,87 @@ private fun DetailsSimilarRail( } } +@Composable +private fun DetailsCollectionRail( + collectionItems: List, + collectionName: String?, + collectionIndex: Int, + focusSectionForUi: FocusSection?, + usePosterCards: Boolean, + contentStartPadding: Dp, + contentOuterStartPadding: Dp, + onCollectionClick: (Int) -> Unit +) { + val collectionRowState = rememberTvLazyListState() + val collectionCardWidth = if (usePosterCards) 126.dp else 210.dp + val collectionFixedFocus = focusSectionForUi == FocusSection.COLLECTION && + detailsRailUsesFixedFirstSlotFocus( + totalItems = collectionItems.size, + focusedItemIndex = collectionIndex + ) + HomeStyleRowAutoScroll( + rowState = collectionRowState, + isCurrentRow = focusSectionForUi == FocusSection.COLLECTION, + focusedItemIndex = collectionIndex, + totalItems = collectionItems.size, + itemWidth = collectionCardWidth, + itemSpacing = 14.dp + ) + + Column { + val displayName = collectionName ?: stringResource(R.string.more_like_this) + Text( + text = "$displayName Collection", + style = ArvioSkin.typography.sectionTitle.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold + ), + color = Color.White.copy(alpha = 0.9f), + modifier = Modifier.padding(start = contentStartPadding, bottom = 10.dp) + ) + + Box(modifier = Modifier.fillMaxWidth()) { + TvLazyRow( + state = collectionRowState, + modifier = Modifier.arvioDpadFocusGroup(enableFocusRestorer = false), + contentPadding = PaddingValues( + start = contentStartPadding, + end = lockedDetailsRailEndPadding( + itemWidth = collectionCardWidth, + startPadding = contentStartPadding, + outerStartPadding = contentOuterStartPadding, + minimum = if (usePosterCards) 140.dp else 210.dp + ), + top = 14.dp, + bottom = 14.dp, + ), + horizontalArrangement = Arrangement.spacedBy(14.dp) + ) { + itemsIndexed( + collectionItems, + key = { index, m -> "col_${m.mediaType.name}_${m.id}_$index" } + ) { index, mediaItem -> + SimilarMediaCard( + item = mediaItem, + logoImageUrl = null, + usePosterCards = usePosterCards, + isFocused = focusSectionForUi == FocusSection.COLLECTION && index == collectionIndex && !collectionFixedFocus, + onClick = { onCollectionClick(index) } + ) + } + } + if (collectionFixedFocus) { + FixedDetailsRailFocusOverlay( + startPadding = contentStartPadding, + topPadding = 14.dp, + width = collectionCardWidth, + aspectRatio = if (usePosterCards) 2f / 3f else 16f / 9f + ) + } + } + } +} + @Composable private fun detailsRailIsScrollable( totalItems: Int, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 4b8f45919..205f4908c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -84,7 +84,12 @@ data class DetailsUiState( val playLabel: String? = null, val playPositionMs: Long? = null, val autoPlaySingleSource: Boolean = true, - val autoPlayMinQuality: String = "Any" + val autoPlayMinQuality: String = "Any", + // TMDB collection (franchise) info — populated for movies that belong to a collection + val collectionId: Int? = null, + val collectionName: String? = null, + val collectionItems: List = emptyList(), + val collectionPosterPath: String? = null ) data class StreamingServiceUi( @@ -470,6 +475,34 @@ class DetailsViewModel @Inject constructor( } } + // TMDB collection (franchise) — only for movies + launch { + if (mediaType != MediaType.MOVIE) return@launch + val collectionRef = runCatching { + mediaRepository.getMovieCollectionRef(mediaId) + }.getOrNull() + if (collectionRef != null) { + updateState { state -> + state.copy( + collectionId = collectionRef.id, + collectionName = collectionRef.name, + collectionPosterPath = collectionRef.posterPath + ) + } + // Fetch collection items in background + launch { + val items = runCatching { + mediaRepository.getTmdbCollectionItems(collectionRef.id) + }.getOrNull() ?: emptyList() + updateState { state -> + if (state.collectionId == collectionRef.id) { + state.copy(collectionItems = items) + } else state + } + } + } + } + launch { delay(260L) val servicesResult = runCatching { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 03e24cc07..05c131a68 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -209,4 +209,5 @@ Set Profile PIN Done Connect to ARVIO Cloud + View Collection