This commit is contained in:
i7xre 2026-07-26 21:16:37 +03:00 committed by GitHub
commit 82f5ffb8be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 106 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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

View file

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