From 83cfcccbfe2773bd86854b065a0b846c3d36a633 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 28 Mar 2026 21:30:15 +0530 Subject: [PATCH] feat: Update MetaDetails and DetailCastSection to use MetaPerson for cast representation --- .../app/features/details/MetaDetailsModels.kt | 8 +- .../app/features/details/MetaDetailsParser.kt | 109 +++++++++++++++++- .../details/components/DetailCastSection.kt | 44 ++++--- 3 files changed, 143 insertions(+), 18 deletions(-) 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 4a6126af8..0e55fdc06 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 @@ -13,7 +13,7 @@ data class MetaDetails( val runtime: String? = null, val genres: List = emptyList(), val director: List = emptyList(), - val cast: List = emptyList(), + val cast: List = emptyList(), val country: String? = null, val awards: String? = null, val language: String? = null, @@ -22,6 +22,12 @@ data class MetaDetails( val videos: List = emptyList(), ) +data class MetaPerson( + val name: String, + val role: String? = null, + val photo: String? = null, +) + data class MetaLink( val name: String, val category: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 27dc0259b..0a99d26e5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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 = array(name).mapNotNull { it.jsonPrimitive.contentOrNull?.takeIf(String::isNotBlank) } + private fun JsonObject.stringListOrCsv(name: String): List { + 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 = 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): List { + 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): List { + 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 = + people(name).map(MetaPerson::name) + + private fun JsonObject?.people(name: String): List { + 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): List { + val merged = linkedMapOf() + 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 = array("videos").mapNotNull { element -> val video = element as? JsonObject ?: return@mapNotNull 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 77b0c5ea1..e0bc9790e 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 @@ -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, + cast: List, 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, ),