mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 06:52:18 +00:00
feat: added castscreen
This commit is contained in:
parent
fe2868e068
commit
9bcbe21d46
8 changed files with 817 additions and 2 deletions
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 -> {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue