mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge 0fbb95944c into 19f216bedf
This commit is contained in:
commit
82f5ffb8be
5 changed files with 106 additions and 12 deletions
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue