feat: Update MetaDetails and DetailCastSection to use MetaPerson for cast representation

This commit is contained in:
tapframe 2026-03-28 21:30:15 +05:30
parent 80751fe25b
commit 83cfcccbfe
3 changed files with 143 additions and 18 deletions

View file

@ -13,7 +13,7 @@ data class MetaDetails(
val runtime: String? = null,
val genres: List<String> = emptyList(),
val director: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val cast: List<MetaPerson> = emptyList(),
val country: String? = null,
val awards: String? = null,
val language: String? = null,
@ -22,6 +22,12 @@ data class MetaDetails(
val videos: List<MetaVideo> = emptyList(),
)
data class MetaPerson(
val name: String,
val role: String? = null,
val photo: String? = null,
)
data class MetaLink(
val name: String,
val category: String,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
@ -14,6 +15,7 @@ internal object MetaDetailsParser {
fun parse(payload: String): MetaDetails {
val root = json.parseToJsonElement(payload).jsonObject
val meta = root["meta"]?.jsonObject ?: error("Missing 'meta' in response")
val links = meta.links()
return MetaDetails(
id = meta.requiredString("id"),
@ -27,13 +29,13 @@ internal object MetaDetailsParser {
imdbRating = meta.string("imdbRating"),
runtime = meta.string("runtime"),
genres = meta.stringList("genres"),
director = meta.stringList("director"),
cast = meta.stringList("cast"),
director = meta.directors(links),
cast = meta.cast(links),
country = meta.string("country"),
awards = meta.string("awards"),
language = meta.string("language"),
website = meta.string("website"),
links = meta.links(),
links = links,
videos = meta.videos(),
)
}
@ -50,6 +52,19 @@ internal object MetaDetailsParser {
private fun JsonObject.stringList(name: String): List<String> =
array(name).mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
private fun JsonObject.stringListOrCsv(name: String): List<String> {
val value = this[name] ?: return emptyList()
return when (value) {
is JsonArray -> value.mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) }
is JsonPrimitive -> value.contentOrNull
?.split(',')
?.map(String::trim)
?.filter(String::isNotBlank)
.orEmpty()
else -> emptyList()
}
}
private fun JsonObject.links(): List<MetaLink> =
array("links").mapNotNull { element ->
val link = element as? JsonObject ?: return@mapNotNull null
@ -62,6 +77,94 @@ internal object MetaDetailsParser {
private fun JsonObject.int(name: String): Int? =
this[name]?.jsonPrimitive?.intOrNull
private fun JsonObject.directors(links: List<MetaLink>): List<String> {
val appExtras = this["app_extras"] as? JsonObject
val topLevel = stringListOrCsv("director")
val extraDirectors = appExtras.personNameList("directors")
val linkDirectors = links.filter { link ->
link.category.equals("director", ignoreCase = true) ||
link.category.equals("directors", ignoreCase = true)
}.map(MetaLink::name)
return (topLevel + extraDirectors + linkDirectors)
.map(String::trim)
.filter(String::isNotBlank)
.distinct()
}
private fun JsonObject.cast(links: List<MetaLink>): List<MetaPerson> {
val appExtras = this["app_extras"] as? JsonObject
val appExtraCast = appExtras.people("cast")
val topLevelCast = stringListOrCsv("cast").map { name ->
MetaPerson(name = name)
}
val linkedCast = links.filter { link ->
link.category.equals("cast", ignoreCase = true) ||
link.category.equals("actor", ignoreCase = true) ||
link.category.equals("actors", ignoreCase = true)
}.map { link ->
MetaPerson(name = link.name)
}
return mergePeople(appExtraCast, topLevelCast, linkedCast)
}
private fun JsonObject?.personNameList(name: String): List<String> =
people(name).map(MetaPerson::name)
private fun JsonObject?.people(name: String): List<MetaPerson> {
if (this == null) return emptyList()
return when (val value = this[name]) {
is JsonArray -> value.mapNotNull { element ->
when (element) {
is JsonObject -> {
val personName = element.string("name")?.trim()
?.takeIf(String::isNotBlank)
?: return@mapNotNull null
MetaPerson(
name = personName,
role = element.string("character")?.trim()?.takeIf(String::isNotBlank),
photo = element.string("photo")?.trim()?.takeIf(String::isNotBlank),
)
}
is JsonPrimitive -> element.contentOrNull
?.trim()
?.takeIf(String::isNotBlank)
?.let(::MetaPerson)
else -> null
}
}
is JsonPrimitive -> value.contentOrNull
?.split(',')
?.map(String::trim)
?.filter(String::isNotBlank)
?.map(::MetaPerson)
.orEmpty()
else -> emptyList()
}
}
private fun mergePeople(vararg groups: List<MetaPerson>): List<MetaPerson> {
val merged = linkedMapOf<String, MetaPerson>()
groups.forEach { group ->
group.forEach { person ->
val normalizedName = person.name.trim()
if (normalizedName.isBlank()) return@forEach
val key = normalizedName.lowercase()
val existing = merged[key]
merged[key] = if (existing == null) {
person.copy(name = normalizedName)
} else {
existing.copy(
role = existing.role ?: person.role,
photo = existing.photo ?: person.photo,
)
}
}
}
return merged.values.toList()
}
private fun JsonObject.videos(): List<MetaVideo> =
array("videos").mapNotNull { element ->
val video = element as? JsonObject ?: return@mapNotNull null

View file

@ -10,21 +10,25 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.ui.draw.clip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.nuvio.app.features.details.MetaPerson
@Composable
fun DetailCastSection(
cast: List<String>,
cast: List<MetaPerson>,
modifier: Modifier = Modifier,
) {
if (cast.isEmpty()) return
@ -39,9 +43,12 @@ fun DetailCastSection(
LazyRow(
horizontalArrangement = Arrangement.spacedBy(sizing.avatarGap),
) {
items(cast) { name ->
items(
items = cast,
key = { it.name },
) { person ->
CastItem(
name = name,
person = person,
sizing = sizing,
)
}
@ -52,9 +59,8 @@ fun DetailCastSection(
@Composable
private fun CastItem(
name: String,
person: MetaPerson,
modifier: Modifier = Modifier,
subLabel: String? = null,
sizing: CastSectionSizing,
) {
Column(
@ -65,21 +71,31 @@ private fun CastItem(
Box(
modifier = Modifier
.size(sizing.avatarSize)
.clip(CircleShape)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape,
),
contentAlignment = Alignment.Center,
) {
Text(
text = name.initials(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
)
if (person.photo != null) {
AsyncImage(
model = person.photo,
contentDescription = person.name,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = person.name.initials(),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
)
}
}
Text(
text = name,
text = person.name,
style = MaterialTheme.typography.bodyMedium.copy(
fontSize = sizing.nameLabelSize,
),
@ -88,9 +104,9 @@ private fun CastItem(
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (!subLabel.isNullOrBlank()) {
if (!person.role.isNullOrBlank()) {
Text(
text = subLabel,
text = person.role,
style = MaterialTheme.typography.bodySmall.copy(
fontSize = sizing.subLabelSize,
),