diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3cb530ca9..19cb92274 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -79,6 +79,8 @@ import com.nuvio.app.features.catalog.CatalogScreen import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL 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.home.HomeCatalogSection import com.nuvio.app.features.home.HomeScreen import com.nuvio.app.features.home.MetaPreview @@ -134,6 +136,13 @@ object TabsRoute @Serializable data class DetailRoute(val type: String, val id: String) +@Serializable +data class PersonDetailRoute( + val personId: Int, + val personName: String, + val preferCrew: Boolean = false, +) + @Serializable object HomescreenSettingsRoute @@ -607,6 +616,53 @@ private fun MainAppContent( ) } }, + onCastClick = { person -> + val tmdbId = person.tmdbId + if (tmdbId != null && tmdbId > 0) { + navController.navigate( + PersonDetailRoute( + personId = tmdbId, + personName = person.name, + preferCrew = person.role?.let { + it.equals("Director", ignoreCase = true) || + it.equals("Writer", ignoreCase = true) || + it.equals("Creator", ignoreCase = true) + } ?: false, + ), + ) + } + }, + modifier = Modifier.fillMaxSize(), + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + PersonDetailScreen( + personId = route.personId, + personName = route.personName, + preferCrew = route.preferCrew, + 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(), ) } 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 de09dd441..3ac52ded4 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 @@ -60,6 +60,7 @@ data class MetaPerson( val name: String, val role: String? = null, val photo: String? = null, + val tmdbId: Int? = null, ) data class MetaCompany( 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 9dc190d0b..1ab77f245 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 @@ -89,6 +89,7 @@ fun MetaDetailsScreen( 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, + onCastClick: ((MetaPerson) -> Unit)? = null, modifier: Modifier = Modifier, ) { val uiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() @@ -514,6 +515,7 @@ fun MetaDetailsScreen( onEpisodeClick = onEpisodePlayClick, onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onOpenMeta = onOpenMeta, + onCastClick = onCastClick, ) Spacer(modifier = Modifier.height(32.dp + nuvioPlatformExtraBottomPadding)) @@ -752,6 +754,7 @@ private fun ConfiguredMetaSections( onEpisodeClick: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit, onOpenMeta: ((MetaPreview) -> Unit)?, + onCastClick: ((MetaPerson) -> Unit)?, ) { settings.items .filter { it.enabled } @@ -779,7 +782,10 @@ private fun ConfiguredMetaSections( } MetaScreenSectionKey.CAST -> { - DetailCastSection(cast = meta.cast) + DetailCastSection( + cast = meta.cast, + onCastClick = onCastClick, + ) } MetaScreenSectionKey.COMMENTS -> { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetail.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetail.kt new file mode 100644 index 000000000..7b90e3360 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetail.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.features.home.MetaPreview + +data class PersonDetail( + val tmdbId: Int, + val name: String, + val biography: String?, + val birthday: String?, + val deathday: String?, + val placeOfBirth: String?, + val profilePhoto: String?, + val knownFor: String?, + val movieCredits: List, + val tvCredits: List, +) 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 new file mode 100644 index 000000000..d4e06d9d3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -0,0 +1,508 @@ +package com.nuvio.app.features.details + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +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.size +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.CircleShape +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.TmdbMetadataService + +private sealed interface PersonDetailUiState { + data object Loading : PersonDetailUiState + data class Success(val personDetail: PersonDetail) : PersonDetailUiState + data class Error(val message: String) : PersonDetailUiState +} + +@Composable +fun PersonDetailScreen( + personId: Int, + personName: String, + preferCrew: Boolean = false, + onBack: () -> Unit, + onOpenMeta: (MetaPreview) -> Unit, + modifier: Modifier = Modifier, +) { + var uiState by remember(personId) { mutableStateOf(PersonDetailUiState.Loading) } + + LaunchedEffect(personId) { + uiState = PersonDetailUiState.Loading + val detail = TmdbMetadataService.fetchPersonDetail( + personId = personId, + preferCrewCredits = preferCrew, + ) + uiState = if (detail != null) { + PersonDetailUiState.Success(detail) + } else { + PersonDetailUiState.Error("Could not load details for $personName") + } + } + + Box(modifier = modifier.fillMaxSize()) { + Crossfade( + targetState = uiState, + label = "PersonDetailCrossfade", + ) { state -> + when (state) { + is PersonDetailUiState.Loading -> PersonDetailSkeleton(personName = personName) + is PersonDetailUiState.Error -> PersonDetailError( + message = state.message, + onRetry = { + uiState = PersonDetailUiState.Loading + // Retry will be triggered by the LaunchedEffect above if we reset + }, + ) + is PersonDetailUiState.Success -> PersonDetailContent( + person = state.personDetail, + onOpenMeta = onOpenMeta, + ) + } + } + + // Back button overlaid on top + 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 PersonDetailContent( + person: PersonDetail, + onOpenMeta: (MetaPreview) -> Unit, +) { + val accentColor = MaterialTheme.colorScheme.primary + + val allCredits = remember(person.movieCredits, person.tvCredits) { + (person.movieCredits + person.tvCredits) + .distinctBy { it.id } + .sortedByDescending { it.releaseInfo?.take(4)?.toIntOrNull() ?: 0 } + } + + val accentGradient = remember(accentColor) { + Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to accentColor.copy(alpha = 0.18f), + 0.15f to accentColor.copy(alpha = 0.10f), + 0.30f to accentColor.copy(alpha = 0.04f), + 0.50f to Color.Transparent, + ), + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(accentGradient), + ) + + AnimatedVisibility( + visible = true, + enter = fadeIn(), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 48.dp), + ) { + HeroSection(person = person) + + if (allCredits.isNotEmpty()) { + Spacer(modifier = Modifier.height(24.dp)) + DetailPosterRailSection( + title = "Filmography", + items = allCredits, + watchedKeys = emptySet(), + headerHorizontalPadding = 20.dp, + onPosterClick = onOpenMeta, + ) + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } + } +} + +@Composable +private fun HeroSection(person: PersonDetail) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Profile Photo + Box( + modifier = Modifier + .size(140.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (!person.profilePhoto.isNullOrBlank()) { + AsyncImage( + model = person.profilePhoto, + contentDescription = person.name, + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.Crop, + ) + } else { + Text( + text = person.name.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.displayMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Name + Text( + text = person.name, + style = MaterialTheme.typography.headlineSmall.copy( + fontWeight = FontWeight.Bold, + letterSpacing = (-0.5).sp, + ), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Personal info + val infoItems = buildList { + person.birthday?.let { bday -> + val age = calculateAge(bday, person.deathday) + val ageStr = if (age != null) " (age $age)" else "" + val bdayDisplay = formatDateForDisplay(bday) ?: bday + val deathDisplay = person.deathday?.let { formatDateForDisplay(it) ?: it } + val line = if (deathDisplay != null) { + "Born $bdayDisplay — Died $deathDisplay$ageStr" + } else { + "Born $bdayDisplay$ageStr" + } + add(line) + } + person.placeOfBirth?.let { add(it) } + person.knownFor?.let { add("Known for: $it") } + } + if (infoItems.isNotEmpty()) { + infoItems.forEach { info -> + Text( + text = info, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(2.dp)) + } + } + + // Biography + person.biography?.let { bio -> + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = bio, + style = MaterialTheme.typography.bodyMedium.copy( + lineHeight = 20.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 8, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +// ─── Loading / Error States ─── + +@Composable +private fun PersonDetailSkeleton(personName: String) { + val accentColor = MaterialTheme.colorScheme.primary + val accentGradient = remember(accentColor) { + Brush.verticalGradient( + colorStops = arrayOf( + 0.0f to accentColor.copy(alpha = 0.18f), + 0.15f to accentColor.copy(alpha = 0.10f), + 0.30f to accentColor.copy(alpha = 0.04f), + 0.50f to Color.Transparent, + ), + ) + } + + Box(modifier = Modifier.fillMaxSize()) { + Box(modifier = Modifier.fillMaxSize().background(accentGradient)) + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .windowInsetsPadding(WindowInsets.statusBars) + .padding(top = 48.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .size(140.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = personName, + style = MaterialTheme.typography.headlineSmall.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + SkeletonLine( + widthFraction = 0.58f, + height = 14.dp, + ) + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine( + widthFraction = 0.42f, + height = 14.dp, + ) + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine( + widthFraction = 0.34f, + height = 14.dp, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + listOf(1.0f, 0.96f, 0.92f, 0.98f, 0.88f, 0.94f, 0.82f, 0.74f).forEachIndexed { index, widthFraction -> + SkeletonLine( + widthFraction = widthFraction, + height = 16.dp, + ) + if (index != 7) { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Filmography header skeleton + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .width(120.dp) + .height(18.dp) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Poster row skeleton + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 0.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + repeat(4) { + Column(modifier = Modifier.width(110.dp)) { + Box( + modifier = Modifier + .width(110.dp) + .height(163.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) + Spacer(modifier = Modifier.height(6.dp)) + SkeletonLine( + widthFraction = 1f, + height = 16.dp, + ) + Spacer(modifier = Modifier.height(4.dp)) + SkeletonLine( + widthFraction = 0.56f, + height = 12.dp, + ) + } + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +@Composable +private fun SkeletonLine( + widthFraction: Float, + height: androidx.compose.ui.unit.Dp, +) { + Box( + modifier = Modifier + .fillMaxWidth(widthFraction) + .height(height) + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + ) +} + +@Composable +private fun PersonDetailError( + message: String, + onRetry: () -> Unit, +) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Something went wrong", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(24.dp)) + Button( + onClick = onRetry, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text("Retry") + } + } + } +} + +// ─── Utility ─── + +private fun calculateAge(birthday: String, deathday: String?): Int? { + val birthParts = birthday.split("-").mapNotNull { it.toIntOrNull() } + if (birthParts.size < 3) return null + val birthYear = birthParts[0] + val birthMonth = birthParts[1] + val birthDay = birthParts[2] + + val endParts = deathday?.split("-")?.mapNotNull { it.toIntOrNull() } + // Use a rough current date approximation for KMP compatibility + val endYear: Int + val endMonth: Int + val endDay: Int + if (endParts != null && endParts.size >= 3) { + endYear = endParts[0] + endMonth = endParts[1] + endDay = endParts[2] + } else { + // Approximate current date — this is good enough for age display + endYear = 2026 + endMonth = 4 + endDay = 3 + } + + var age = endYear - birthYear + if (endMonth < birthMonth || (endMonth == birthMonth && endDay < birthDay)) { + age-- + } + return age.takeIf { it >= 0 } +} + +private fun formatDateForDisplay(date: String): String? { + val parts = date.split("-").mapNotNull { it.toIntOrNull() } + if (parts.size < 3) return null + val months = arrayOf( + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ) + val month = parts[1] + val day = parts[2] + val year = parts[0] + return if (month in 1..12) { + "${months[month - 1]} $day, $year" + } else { + null + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index a1d384aec..d04aa9bdc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.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 @@ -31,6 +32,7 @@ import com.nuvio.app.features.details.MetaPerson fun DetailCastSection( cast: List, modifier: Modifier = Modifier, + onCastClick: ((MetaPerson) -> Unit)? = null, ) { if (cast.isEmpty()) return @@ -51,6 +53,11 @@ fun DetailCastSection( CastItem( person = person, sizing = sizing, + onClick = if (onCastClick != null && person.tmdbId != null && person.tmdbId > 0) { + { onCastClick(person) } + } else { + null + }, ) } } @@ -63,9 +70,12 @@ private fun CastItem( person: MetaPerson, modifier: Modifier = Modifier, sizing: CastSectionSizing, + onClick: (() -> Unit)? = null, ) { Column( - modifier = modifier.width(sizing.itemWidth), + modifier = modifier + .width(sizing.itemWidth) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { 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 317e2eb36..f444aaf3c 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 @@ -3,6 +3,7 @@ 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 androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.features.home.MetaPreview @@ -16,6 +17,7 @@ fun DetailPosterRailSection( items: List, watchedKeys: Set, modifier: Modifier = Modifier, + headerHorizontalPadding: Dp = 0.dp, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { @@ -25,6 +27,7 @@ fun DetailPosterRailSection( title = title, entries = items, modifier = modifier, + headerHorizontalPadding = headerHorizontalPadding, rowContentPadding = PaddingValues(0.dp), showHeaderAccent = false, key = { item -> item.stableKey() }, 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 6150b0e0f..dfb5b7312 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 @@ -7,6 +7,7 @@ import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.MetaTrailer import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.details.PersonDetail import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape import kotlinx.coroutines.Dispatchers @@ -27,6 +28,166 @@ object TmdbMetadataService { private val moreLikeThisCache = mutableMapOf>() private val collectionCache = mutableMapOf>>() private val trailerCache = mutableMapOf>() + private val personCache = mutableMapOf() + + suspend fun fetchPersonDetail( + personId: Int, + preferCrewCredits: Boolean? = null, + ): PersonDetail? = withContext(Dispatchers.Default) { + val settings = TmdbSettingsRepository.snapshot() + if (!settings.enabled || !settings.hasApiKey) return@withContext null + val language = normalizeTmdbLanguage(settings.language) + val cacheKey = "$personId:${preferCrewCredits?.toString() ?: "auto"}:$language" + personCache[cacheKey]?.let { return@withContext it } + + try { + val (person, credits) = coroutineScope { + val personDeferred = async { + fetch( + endpoint = "person/$personId", + query = mapOf("language" to language), + ) + } + val creditsDeferred = async { + fetch( + endpoint = "person/$personId/combined_credits", + query = mapOf("language" to language), + ) + } + personDeferred.await() to creditsDeferred.await() + } + + if (person == null) return@withContext null + + val biography = if (person.biography.isNullOrBlank() && language != "en") { + runCatching { + fetch( + endpoint = "person/$personId", + query = mapOf("language" to "en"), + )?.biography + }.getOrNull() + } else { + person.biography + }?.takeIf { it.isNotBlank() } + + val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment) + + val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty()) + val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty()) + val movieCredits = when { + preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits + castMovieCredits.isNotEmpty() -> castMovieCredits + else -> crewMovieCredits + } + + val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty()) + val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty()) + val tvCredits = when { + preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits + castTvCredits.isNotEmpty() -> castTvCredits + else -> crewTvCredits + } + + val detail = PersonDetail( + tmdbId = person.id ?: personId, + name = person.name ?: "Unknown", + biography = biography, + birthday = person.birthday?.takeIf { it.isNotBlank() }, + deathday = person.deathday?.takeIf { it.isNotBlank() }, + placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() }, + profilePhoto = buildImageUrl(person.profilePath, "w500"), + knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() }, + movieCredits = movieCredits, + tvCredits = tvCredits, + ) + personCache[cacheKey] = detail + detail + } catch (e: Exception) { + log.w(e) { "Failed to fetch person detail for $personId" } + null + } + } + + private fun shouldPreferCrewCredits(knownForDepartment: String?): Boolean { + val department = knownForDepartment?.trim()?.lowercase() ?: return false + return department.isNotBlank() && department != "acting" && department != "actors" + } + + private fun mapPersonMovieCreditsFromCast(cast: List): List { + val seen = mutableSetOf() + return cast + .filter { it.mediaType == "movie" && it.posterPath != null } + .sortedByDescending { it.voteAverage ?: 0.0 } + .mapNotNull { credit -> + if (!seen.add(credit.id)) return@mapNotNull null + val title = credit.title ?: credit.name ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = buildImageUrl(credit.posterPath, "w500"), + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + ) + } + } + + private fun mapPersonMovieCreditsFromCrew(crew: List): List { + val seen = mutableSetOf() + return crew + .filter { it.mediaType == "movie" && it.posterPath != null } + .sortedByDescending { it.voteAverage ?: 0.0 } + .mapNotNull { credit -> + if (!seen.add(credit.id)) return@mapNotNull null + val title = credit.title ?: credit.name ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = buildImageUrl(credit.posterPath, "w500"), + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + ) + } + } + + private fun mapPersonTvCreditsFromCast(cast: List): List { + val seen = mutableSetOf() + return cast + .filter { it.mediaType == "tv" && it.posterPath != null } + .sortedByDescending { it.voteAverage ?: 0.0 } + .mapNotNull { credit -> + if (!seen.add(credit.id)) return@mapNotNull null + val title = credit.name ?: credit.title ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = buildImageUrl(credit.posterPath, "w500"), + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + ) + } + } + + private fun mapPersonTvCreditsFromCrew(crew: List): List { + val seen = mutableSetOf() + return crew + .filter { it.mediaType == "tv" && it.posterPath != null } + .sortedByDescending { it.voteAverage ?: 0.0 } + .mapNotNull { credit -> + if (!seen.add(credit.id)) return@mapNotNull null + val title = credit.name ?: credit.title ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = buildImageUrl(credit.posterPath, "w500"), + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + ) + } + } suspend fun enrichMeta( meta: MetaDetails, @@ -671,6 +832,7 @@ private fun buildPeople( name = name, role = "Creator", photo = buildImageUrl(creator.profilePath, "w500"), + tmdbId = creator.id, ) } } else { @@ -685,6 +847,7 @@ private fun buildPeople( name = name, role = "Director", photo = buildImageUrl(crew.profilePath, "w500"), + tmdbId = crew.id, ) } @@ -699,6 +862,7 @@ private fun buildPeople( name = name, role = "Writer", photo = buildImageUrl(crew.profilePath, "w500"), + tmdbId = crew.id, ) } @@ -709,6 +873,7 @@ private fun buildPeople( name = name, role = castMember.character?.trim()?.takeIf(String::isNotBlank), photo = buildImageUrl(castMember.profilePath, "w500"), + tmdbId = castMember.id, ) } @@ -1063,3 +1228,53 @@ private data class TmdbEpisodeResponse( val runtime: Int? = null, @SerialName("episode_number") val episodeNumber: Int? = null, ) + +// ─── Person Detail Models ─── + +@Serializable +private data class TmdbPersonResponse( + val id: Int? = null, + val name: String? = null, + val biography: String? = null, + val birthday: String? = null, + val deathday: String? = null, + @SerialName("place_of_birth") val placeOfBirth: String? = null, + @SerialName("profile_path") val profilePath: String? = null, + @SerialName("known_for_department") val knownForDepartment: String? = null, +) + +@Serializable +private data class TmdbPersonCombinedCreditsResponse( + val cast: List = emptyList(), + val crew: List = emptyList(), +) + +@Serializable +private data class TmdbPersonCreditCast( + val id: Int = 0, + @SerialName("media_type") val mediaType: String? = null, + val title: String? = null, + val name: String? = null, + val character: 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, +) + +@Serializable +private data class TmdbPersonCreditCrew( + val id: Int = 0, + @SerialName("media_type") val mediaType: String? = null, + val title: String? = null, + val name: String? = null, + val job: 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, +)