From 0fbb95944c5cef7b688038035b936e6140b14ea7 Mon Sep 17 00:00:00 2001 From: Hassan Date: Sun, 26 Jul 2026 21:06:38 +0300 Subject: [PATCH] feat(details): show episode-specific runtime on episode cards --- .../app/features/details/MetaDetailsParser.kt | 2 +- .../app/features/details/RuntimeFormat.kt | 13 +++-- .../details/components/DetailSeriesContent.kt | 58 +++++++++++++++++-- .../features/details/MetaDetailsParserTest.kt | 29 ++++++++++ .../components/EpisodeCardRulesTest.kt | 16 +++++ 5 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeCardRulesTest.kt 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 21e8b4aa9..7dd4e51c9 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 @@ -234,7 +234,7 @@ internal object MetaDetailsParser { season = video.int("season"), episode = video.int("episode"), overview = video.string("overview") ?: video.string("description"), - runtime = video.int("runtime"), + runtime = parseRuntimeMinutes(video.string("runtime"))?.takeIf { it > 0 }, streams = video.embeddedStreams(), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt index 05704df93..adfb3f933 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/RuntimeFormat.kt @@ -32,22 +32,25 @@ internal fun formatRuntimeFromMinutes(totalMinutes: Int): String { } } -private fun parseRuntimeMinutes(value: String): Int? { - hourMinuteColonRegex.matchEntire(value)?.let { match -> +internal fun parseRuntimeMinutes(value: String?): Int? { + val normalized = value?.trim()?.takeIf { it.isNotBlank() } ?: return null + if (normalized.startsWith("-")) return null + + hourMinuteColonRegex.matchEntire(normalized)?.let { match -> val hours = match.groupValues[1].toIntOrNull() ?: return null val minutes = match.groupValues[2].toIntOrNull() ?: return null return (hours * 60) + minutes } - val hoursToken = hourTokenRegex.find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() - val minutesToken = minuteTokenRegex.find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() + val hoursToken = hourTokenRegex.find(normalized)?.groupValues?.getOrNull(1)?.toIntOrNull() + val minutesToken = minuteTokenRegex.find(normalized)?.groupValues?.getOrNull(1)?.toIntOrNull() if (hoursToken != null || minutesToken != null) { val hours = (hoursToken ?: 0).coerceAtLeast(0) val minutes = (minutesToken ?: 0).coerceAtLeast(0) return (hours * 60) + minutes } - digitsOnlyRegex.matchEntire(value)?.let { match -> + digitsOnlyRegex.matchEntire(normalized)?.let { match -> return match.groupValues[1].toIntOrNull()?.coerceAtLeast(0) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 71ba2eded..9645c6833 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -36,6 +36,9 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -684,7 +687,7 @@ private fun EpisodeHorizontalCard( val cardShape = RoundedCornerShape(metrics.cornerRadius) val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) } val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } } - val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) } + val runtimeLabel = remember(video.runtime) { episodeRuntimeLabel(video.runtime) } val imageUrl = video.thumbnail ?: fallbackImage Box( modifier = Modifier @@ -791,11 +794,11 @@ private fun EpisodeHorizontalCard( verticalAlignment = Alignment.CenterVertically, ) { runtimeLabel?.let { runtime -> - Text( - text = runtime, - style = MaterialTheme.typography.labelSmall.copy(fontSize = metrics.metaTextSize), + EpisodeRuntimeMetadata( + label = runtime, + iconSize = metrics.imdbLogoHeight + 1.dp, + textSize = metrics.metaTextSize, color = Color.White.copy(alpha = 0.78f), - maxLines = 1, ) } ratingLabel?.let { rating -> @@ -964,6 +967,40 @@ private fun formatEpisodeRuntime(runtimeMinutes: Int): String { return formatRuntimeFromMinutes(runtimeMinutes) } +internal fun episodeRuntimeLabel(runtimeMinutes: Int?): String? = + runtimeMinutes + ?.takeIf(::shouldShowEpisodeRuntime) + ?.let(::formatEpisodeRuntime) + +internal fun shouldShowEpisodeRuntime(runtimeMinutes: Int?): Boolean = + runtimeMinutes != null && runtimeMinutes > 0 + +@Composable +private fun EpisodeRuntimeMetadata( + label: String, + iconSize: Dp, + textSize: androidx.compose.ui.unit.TextUnit, + color: Color, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Schedule, + contentDescription = null, + tint = color, + modifier = Modifier.size(iconSize), + ) + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy(fontSize = textSize), + color = color, + maxLines = 1, + ) + } +} + @Composable private fun EpisodeCodeBadge( text: String, @@ -1054,6 +1091,7 @@ private fun EpisodeListCard( val cardShape = RoundedCornerShape(sizing.cardRadius) val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) } val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } } + val runtimeLabel = remember(video.runtime) { episodeRuntimeLabel(video.runtime) } Box( modifier = modifier .fillMaxWidth() @@ -1145,11 +1183,19 @@ private fun EpisodeListCard( overflow = TextOverflow.Ellipsis, ) - if (formattedDate != null || ratingLabel != null) { + if (runtimeLabel != null || formattedDate != null || ratingLabel != null) { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically, ) { + runtimeLabel?.let { runtime -> + EpisodeRuntimeMetadata( + label = runtime, + iconSize = 13.dp, + textSize = sizing.metaTextSize, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + } formattedDate?.let { date -> Text( text = date, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index bfcbaafaa..b1fbbc999 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -4,6 +4,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class MetaDetailsParserTest { @@ -66,6 +67,34 @@ class MetaDetailsParserTest { assertTrue(result.videos[1].available) } + @Test + fun `parse normalizes valid runtime and rejects missing or invalid values`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "show", + "type": "series", + "name": "Show", + "videos": [ + {"id": "valid", "title": "Valid", "runtime": "1h 5m"}, + {"id": "missing", "title": "Missing"}, + {"id": "invalid", "title": "Invalid", "runtime": "unknown"}, + {"id": "zero", "title": "Zero", "runtime": 0}, + {"id": "negative", "title": "Negative", "runtime": -20} + ] + } + } + """.trimIndent(), + ) + + assertEquals(65, result.videos[0].runtime) + assertNull(result.videos[1].runtime) + assertNull(result.videos[2].runtime) + assertNull(result.videos[3].runtime) + assertNull(result.videos[4].runtime) + } + @Test fun `parse reads defaultVideoId from behavior hints`() { val result = MetaDetailsParser.parse( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeCardRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeCardRulesTest.kt new file mode 100644 index 000000000..a2b21d4ed --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/components/EpisodeCardRulesTest.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.details.components + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class EpisodeCardRulesTest { + + @Test + fun `runtime metadata rule requires positive minutes`() { + assertTrue(shouldShowEpisodeRuntime(25)) + assertFalse(shouldShowEpisodeRuntime(null)) + assertFalse(shouldShowEpisodeRuntime(0)) + assertFalse(shouldShowEpisodeRuntime(-1)) + } +}