feat: added castscreen

This commit is contained in:
tapframe 2026-04-03 01:26:43 +05:30
parent fe2868e068
commit 9bcbe21d46
8 changed files with 817 additions and 2 deletions

View file

@ -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<PersonDetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<PersonDetailRoute>()
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(),
)
}

View file

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

View file

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

View file

@ -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<MetaPreview>,
val tvCredits: List<MetaPreview>,
)

View file

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

View file

@ -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<MetaPerson>,
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),
) {

View file

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

View file

@ -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<String, List<MetaPreview>>()
private val collectionCache = mutableMapOf<String, Pair<String?, List<MetaPreview>>>()
private val trailerCache = mutableMapOf<String, List<MetaTrailer>>()
private val personCache = mutableMapOf<String, PersonDetail>()
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<TmdbPersonResponse>(
endpoint = "person/$personId",
query = mapOf("language" to language),
)
}
val creditsDeferred = async {
fetch<TmdbPersonCombinedCreditsResponse>(
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<TmdbPersonResponse>(
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<TmdbPersonCreditCast>): List<MetaPreview> {
val seen = mutableSetOf<Int>()
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<TmdbPersonCreditCrew>): List<MetaPreview> {
val seen = mutableSetOf<Int>()
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<TmdbPersonCreditCast>): List<MetaPreview> {
val seen = mutableSetOf<Int>()
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<TmdbPersonCreditCrew>): List<MetaPreview> {
val seen = mutableSetOf<Int>()
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<TmdbPersonCreditCast> = emptyList(),
val crew: List<TmdbPersonCreditCrew> = 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,
)