mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-20 08:51:45 +00:00
faet: parental guilde overlay
This commit is contained in:
parent
ac48ab11b2
commit
1df74ea0fe
6 changed files with 600 additions and 109 deletions
|
|
@ -1312,6 +1312,14 @@
|
||||||
<string name="notifications_episode_release_body_generic">A new episode is out now</string>
|
<string name="notifications_episode_release_body_generic">A new episode is out now</string>
|
||||||
<string name="notifications_episode_release_body_title">%1$s is out now</string>
|
<string name="notifications_episode_release_body_title">%1$s is out now</string>
|
||||||
<string name="notifications_channel_episode_releases_name">Episode Releases</string>
|
<string name="notifications_channel_episode_releases_name">Episode Releases</string>
|
||||||
|
<string name="parental_alcohol">Alcohol/Drugs</string>
|
||||||
|
<string name="parental_frightening">Frightening</string>
|
||||||
|
<string name="parental_nudity">Nudity</string>
|
||||||
|
<string name="parental_profanity">Profanity</string>
|
||||||
|
<string name="parental_severity_mild">Mild</string>
|
||||||
|
<string name="parental_severity_moderate">Moderate</string>
|
||||||
|
<string name="parental_severity_severe">Severe</string>
|
||||||
|
<string name="parental_violence">Violence</string>
|
||||||
<string name="person_role_creator">Creator</string>
|
<string name="person_role_creator">Creator</string>
|
||||||
<string name="person_role_director">Director</string>
|
<string name="person_role_director">Director</string>
|
||||||
<string name="person_role_writer">Writer</string>
|
<string name="person_role_writer">Writer</string>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
private val ParentalGuideRowHeight = 18.dp
|
||||||
|
private val ParentalGuideRowGap = 2.dp
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ParentalGuideOverlay(
|
||||||
|
warnings: List<ParentalWarning>,
|
||||||
|
isVisible: Boolean,
|
||||||
|
onAnimationComplete: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
contentPadding: PaddingValues = PaddingValues(start = 32.dp, top = 24.dp),
|
||||||
|
) {
|
||||||
|
if (warnings.isEmpty()) return
|
||||||
|
|
||||||
|
val count = warnings.size
|
||||||
|
val totalLineHeight = (ParentalGuideRowHeight.value * count) +
|
||||||
|
(ParentalGuideRowGap.value * (count - 1))
|
||||||
|
|
||||||
|
val containerAlpha = remember { Animatable(0f) }
|
||||||
|
val lineHeightFraction = remember { Animatable(0f) }
|
||||||
|
val itemAlphas = remember(count) { List(count) { Animatable(0f) } }
|
||||||
|
var animating by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(isVisible) {
|
||||||
|
if (isVisible && !animating) {
|
||||||
|
animating = true
|
||||||
|
|
||||||
|
containerAlpha.animateTo(1f, tween(300))
|
||||||
|
lineHeightFraction.animateTo(1f, tween(400, easing = FastOutSlowInEasing))
|
||||||
|
|
||||||
|
for (i in 0 until count) {
|
||||||
|
delay(80)
|
||||||
|
itemAlphas[i].animateTo(1f, tween(200))
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(5000)
|
||||||
|
|
||||||
|
for (i in (count - 1) downTo 0) {
|
||||||
|
delay(60)
|
||||||
|
itemAlphas[i].animateTo(0f, tween(150))
|
||||||
|
}
|
||||||
|
|
||||||
|
delay(100)
|
||||||
|
lineHeightFraction.animateTo(0f, tween(300, easing = FastOutSlowInEasing))
|
||||||
|
|
||||||
|
delay(200)
|
||||||
|
containerAlpha.animateTo(0f, tween(200))
|
||||||
|
|
||||||
|
animating = false
|
||||||
|
onAnimationComplete()
|
||||||
|
} else if (!isVisible && animating) {
|
||||||
|
for (i in (count - 1) downTo 0) {
|
||||||
|
itemAlphas[i].snapTo(0f)
|
||||||
|
}
|
||||||
|
lineHeightFraction.snapTo(0f)
|
||||||
|
containerAlpha.snapTo(0f)
|
||||||
|
animating = false
|
||||||
|
onAnimationComplete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerAlpha.value <= 0f) return
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier
|
||||||
|
.alpha(containerAlpha.value)
|
||||||
|
.padding(contentPadding),
|
||||||
|
verticalAlignment = Alignment.Top,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(3.dp)
|
||||||
|
.height((totalLineHeight * lineHeightFraction.value).dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.primary),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(start = 10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(ParentalGuideRowGap),
|
||||||
|
) {
|
||||||
|
warnings.forEachIndexed { index, warning ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(ParentalGuideRowHeight)
|
||||||
|
.alpha(itemAlphas.getOrNull(index)?.value ?: 0f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = warning.label,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.85f),
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = " · ",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.4f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = warning.severity,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,175 @@
|
||||||
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import com.nuvio.app.features.addons.httpRequestRaw
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
private const val PARENTAL_GUIDE_BASE_URL = "https://api.imdbapi.dev"
|
||||||
|
private val imdbIdPattern = Regex("tt\\d+")
|
||||||
|
|
||||||
|
data class ParentalGuideResult(
|
||||||
|
val nudity: String? = null,
|
||||||
|
val violence: String? = null,
|
||||||
|
val profanity: String? = null,
|
||||||
|
val alcohol: String? = null,
|
||||||
|
val frightening: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ParentalWarning(
|
||||||
|
val label: String,
|
||||||
|
val severity: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal data class ParentalGuideLabels(
|
||||||
|
val nudity: String,
|
||||||
|
val violence: String,
|
||||||
|
val profanity: String,
|
||||||
|
val alcohol: String,
|
||||||
|
val frightening: String,
|
||||||
|
val severe: String,
|
||||||
|
val moderate: String,
|
||||||
|
val mild: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal object ParentalGuideRepository {
|
||||||
|
private val log = Logger.withTag("ParentalGuide")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
private val cache = mutableMapOf<String, ParentalGuideResult?>()
|
||||||
|
private val cacheMutex = Mutex()
|
||||||
|
|
||||||
|
suspend fun getParentalGuide(imdbId: String): ParentalGuideResult? {
|
||||||
|
val normalizedImdbId = extractParentalGuideImdbId(imdbId) ?: return null
|
||||||
|
|
||||||
|
cacheMutex.withLock {
|
||||||
|
if (cache.containsKey(normalizedImdbId)) {
|
||||||
|
return cache[normalizedImdbId]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = runCatching {
|
||||||
|
val response = httpRequestRaw(
|
||||||
|
method = "GET",
|
||||||
|
url = "$PARENTAL_GUIDE_BASE_URL/titles/$normalizedImdbId/parentsGuide",
|
||||||
|
headers = mapOf("Accept" to "application/json"),
|
||||||
|
body = "",
|
||||||
|
)
|
||||||
|
if (response.status !in 200..299 || response.body.isBlank()) {
|
||||||
|
return@runCatching null
|
||||||
|
}
|
||||||
|
val body = json.decodeFromString<ImdbApiParentsGuideResponse>(response.body)
|
||||||
|
val categories = body.parentsGuide
|
||||||
|
if (categories.isEmpty()) null else mapParentalGuideCategoriesToResult(categories)
|
||||||
|
}.onFailure { error ->
|
||||||
|
log.w(error) { "Failed to fetch parental guide for $normalizedImdbId" }
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
cacheMutex.withLock {
|
||||||
|
cache[normalizedImdbId] = result
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun mapParentalGuideCategoriesToResult(
|
||||||
|
categories: List<ImdbApiParentsGuideCategory>,
|
||||||
|
): ParentalGuideResult {
|
||||||
|
val categoryMap = categories.associateBy { it.category.uppercase() }
|
||||||
|
|
||||||
|
return ParentalGuideResult(
|
||||||
|
nudity = resolveParentalGuideSeverity(categoryMap["SEXUAL_CONTENT"]),
|
||||||
|
violence = resolveParentalGuideSeverity(categoryMap["VIOLENCE"]),
|
||||||
|
profanity = resolveParentalGuideSeverity(categoryMap["PROFANITY"]),
|
||||||
|
alcohol = resolveParentalGuideSeverity(categoryMap["ALCOHOL_DRUGS"]),
|
||||||
|
frightening = resolveParentalGuideSeverity(categoryMap["FRIGHTENING_INTENSE_SCENES"]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resolveParentalGuideSeverity(category: ImdbApiParentsGuideCategory?): String? {
|
||||||
|
val breakdowns = category?.severityBreakdowns ?: return null
|
||||||
|
val dominant = breakdowns
|
||||||
|
.filter { it.severityLevel.lowercase() != "none" }
|
||||||
|
.maxByOrNull { it.voteCount }
|
||||||
|
val noneVotes = breakdowns
|
||||||
|
.firstOrNull { it.severityLevel.lowercase() == "none" }
|
||||||
|
?.voteCount ?: 0
|
||||||
|
|
||||||
|
if (dominant == null || dominant.voteCount <= noneVotes) return null
|
||||||
|
return dominant.severityLevel.lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildParentalWarnings(
|
||||||
|
guide: ParentalGuideResult,
|
||||||
|
labels: ParentalGuideLabels,
|
||||||
|
): List<ParentalWarning> {
|
||||||
|
val severityOrder = mapOf(
|
||||||
|
"severe" to 0,
|
||||||
|
"moderate" to 1,
|
||||||
|
"mild" to 2,
|
||||||
|
)
|
||||||
|
|
||||||
|
return listOfNotNull(
|
||||||
|
guide.nudity?.let { "nudity" to it },
|
||||||
|
guide.violence?.let { "violence" to it },
|
||||||
|
guide.profanity?.let { "profanity" to it },
|
||||||
|
guide.alcohol?.let { "alcohol" to it },
|
||||||
|
guide.frightening?.let { "frightening" to it },
|
||||||
|
)
|
||||||
|
.sortedBy { severityOrder[it.second.lowercase()] ?: 3 }
|
||||||
|
.map { (category, severity) ->
|
||||||
|
ParentalWarning(
|
||||||
|
label = when (category) {
|
||||||
|
"nudity" -> labels.nudity
|
||||||
|
"violence" -> labels.violence
|
||||||
|
"profanity" -> labels.profanity
|
||||||
|
"alcohol" -> labels.alcohol
|
||||||
|
"frightening" -> labels.frightening
|
||||||
|
else -> category
|
||||||
|
},
|
||||||
|
severity = when (severity.lowercase()) {
|
||||||
|
"severe" -> labels.severe
|
||||||
|
"moderate" -> labels.moderate
|
||||||
|
"mild" -> labels.mild
|
||||||
|
else -> severity
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.take(5)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun extractParentalGuideImdbId(value: String?): String? =
|
||||||
|
value
|
||||||
|
?.let { imdbIdPattern.find(it)?.value }
|
||||||
|
?.takeIf { it.startsWith("tt") }
|
||||||
|
|
||||||
|
internal fun extractParentalGuideTmdbId(value: String?): Int? {
|
||||||
|
val normalized = value?.trim()?.takeIf(String::isNotBlank) ?: return null
|
||||||
|
if (normalized.all(Char::isDigit)) return normalized.toIntOrNull()
|
||||||
|
if (normalized.startsWith("tmdb:", ignoreCase = true)) {
|
||||||
|
return normalized.substringAfter(':').substringBefore(':').toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
val tokens = normalized.split(':', '/', '|')
|
||||||
|
val tmdbIndex = tokens.indexOfFirst { it.equals("tmdb", ignoreCase = true) }
|
||||||
|
return tokens.getOrNull(tmdbIndex + 1)?.toIntOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ImdbApiParentsGuideResponse(
|
||||||
|
@SerialName("parentsGuide") val parentsGuide: List<ImdbApiParentsGuideCategory> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ImdbApiParentsGuideCategory(
|
||||||
|
@SerialName("category") val category: String = "",
|
||||||
|
@SerialName("severityBreakdowns") val severityBreakdowns: List<ImdbApiSeverityBreakdown>? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
internal data class ImdbApiSeverityBreakdown(
|
||||||
|
@SerialName("severityLevel") val severityLevel: String = "",
|
||||||
|
@SerialName("voteCount") val voteCount: Int = 0,
|
||||||
|
)
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package com.nuvio.app.features.player
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
|
@ -37,6 +40,7 @@ import androidx.compose.material3.SliderDefaults
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -69,6 +73,7 @@ internal fun PlayerControlsShell(
|
||||||
metrics: PlayerLayoutMetrics,
|
metrics: PlayerLayoutMetrics,
|
||||||
resizeMode: PlayerResizeMode,
|
resizeMode: PlayerResizeMode,
|
||||||
isLocked: Boolean,
|
isLocked: Boolean,
|
||||||
|
showPlaybackControls: Boolean = true,
|
||||||
onLockToggle: () -> Unit,
|
onLockToggle: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onTogglePlayback: () -> Unit,
|
onTogglePlayback: () -> Unit,
|
||||||
|
|
@ -81,6 +86,9 @@ internal fun PlayerControlsShell(
|
||||||
onSourcesClick: (() -> Unit)? = null,
|
onSourcesClick: (() -> Unit)? = null,
|
||||||
onEpisodesClick: (() -> Unit)? = null,
|
onEpisodesClick: (() -> Unit)? = null,
|
||||||
onSubmitIntroClick: (() -> Unit)? = null,
|
onSubmitIntroClick: (() -> Unit)? = null,
|
||||||
|
parentalWarnings: List<ParentalWarning> = emptyList(),
|
||||||
|
showParentalGuide: Boolean = false,
|
||||||
|
onParentalGuideAnimationComplete: () -> Unit = {},
|
||||||
onScrubChange: (Long) -> Unit,
|
onScrubChange: (Long) -> Unit,
|
||||||
onScrubFinished: (Long) -> Unit,
|
onScrubFinished: (Long) -> Unit,
|
||||||
horizontalSafePadding: androidx.compose.ui.unit.Dp,
|
horizontalSafePadding: androidx.compose.ui.unit.Dp,
|
||||||
|
|
@ -131,7 +139,11 @@ internal fun PlayerControlsShell(
|
||||||
episodeTitle = episodeTitle,
|
episodeTitle = episodeTitle,
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
isLocked = isLocked,
|
isLocked = isLocked,
|
||||||
|
showActions = showPlaybackControls,
|
||||||
onSubmitIntroClick = onSubmitIntroClick,
|
onSubmitIntroClick = onSubmitIntroClick,
|
||||||
|
parentalWarnings = parentalWarnings,
|
||||||
|
showParentalGuide = showParentalGuide,
|
||||||
|
onParentalGuideAnimationComplete = onParentalGuideAnimationComplete,
|
||||||
onLockToggle = onLockToggle,
|
onLockToggle = onLockToggle,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -145,36 +157,40 @@ internal fun PlayerControlsShell(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
CenterControls(
|
if (showPlaybackControls) {
|
||||||
snapshot = playbackSnapshot,
|
CenterControls(
|
||||||
metrics = metrics,
|
snapshot = playbackSnapshot,
|
||||||
onSeekBack = onSeekBack,
|
metrics = metrics,
|
||||||
onSeekForward = onSeekForward,
|
onSeekBack = onSeekBack,
|
||||||
onTogglePlayback = onTogglePlayback,
|
onSeekForward = onSeekForward,
|
||||||
modifier = Modifier
|
onTogglePlayback = onTogglePlayback,
|
||||||
.align(Alignment.Center)
|
modifier = Modifier
|
||||||
.padding(bottom = metrics.centerLift),
|
.align(Alignment.Center)
|
||||||
)
|
.padding(bottom = metrics.centerLift),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ProgressControls(
|
if (showPlaybackControls) {
|
||||||
playbackSnapshot = playbackSnapshot,
|
ProgressControls(
|
||||||
displayedPositionMs = displayedPositionMs,
|
playbackSnapshot = playbackSnapshot,
|
||||||
metrics = metrics,
|
displayedPositionMs = displayedPositionMs,
|
||||||
resizeMode = resizeMode,
|
metrics = metrics,
|
||||||
onScrubChange = onScrubChange,
|
resizeMode = resizeMode,
|
||||||
onScrubFinished = onScrubFinished,
|
onScrubChange = onScrubChange,
|
||||||
onResizeModeClick = onResizeModeClick,
|
onScrubFinished = onScrubFinished,
|
||||||
onSpeedClick = onSpeedClick,
|
onResizeModeClick = onResizeModeClick,
|
||||||
onSubtitleClick = onSubtitleClick,
|
onSpeedClick = onSpeedClick,
|
||||||
onAudioClick = onAudioClick,
|
onSubtitleClick = onSubtitleClick,
|
||||||
onSourcesClick = onSourcesClick,
|
onAudioClick = onAudioClick,
|
||||||
onEpisodesClick = onEpisodesClick,
|
onSourcesClick = onSourcesClick,
|
||||||
modifier = Modifier
|
onEpisodesClick = onEpisodesClick,
|
||||||
.align(Alignment.BottomCenter)
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.align(Alignment.BottomCenter)
|
||||||
.padding(horizontal = metrics.horizontalPadding)
|
.fillMaxWidth()
|
||||||
.padding(bottom = metrics.sliderBottomOffset),
|
.padding(horizontal = metrics.horizontalPadding)
|
||||||
)
|
.padding(bottom = metrics.sliderBottomOffset),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -189,110 +205,131 @@ private fun PlayerHeader(
|
||||||
episodeTitle: String?,
|
episodeTitle: String?,
|
||||||
metrics: PlayerLayoutMetrics,
|
metrics: PlayerLayoutMetrics,
|
||||||
isLocked: Boolean,
|
isLocked: Boolean,
|
||||||
|
showActions: Boolean,
|
||||||
onSubmitIntroClick: (() -> Unit)?,
|
onSubmitIntroClick: (() -> Unit)?,
|
||||||
|
parentalWarnings: List<ParentalWarning>,
|
||||||
|
showParentalGuide: Boolean,
|
||||||
|
onParentalGuideAnimationComplete: () -> Unit,
|
||||||
onLockToggle: () -> Unit,
|
onLockToggle: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val typeScale = MaterialTheme.nuvioTypeScale
|
val typeScale = MaterialTheme.nuvioTypeScale
|
||||||
|
val metadataAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (!showParentalGuide && showActions) 1f else 0f,
|
||||||
|
animationSpec = tween(durationMillis = if (!showParentalGuide && showActions) 260 else 160),
|
||||||
|
label = "playerHeaderMetadataAlpha",
|
||||||
|
)
|
||||||
Column(modifier = modifier) {
|
Column(modifier = modifier) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top,
|
||||||
) {
|
) {
|
||||||
Column(
|
Box(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Column(
|
||||||
text = title,
|
modifier = Modifier.graphicsLayer { alpha = metadataAlpha },
|
||||||
style = typeScale.titleLg.copy(
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
fontSize = metrics.titleSize,
|
|
||||||
lineHeight = metrics.titleSize * 1.16f,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
),
|
|
||||||
color = Color.White,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(
|
|
||||||
Res.string.compose_player_episode_title_format,
|
|
||||||
seasonNumber,
|
|
||||||
episodeNumber,
|
|
||||||
episodeTitle,
|
|
||||||
),
|
|
||||||
style = typeScale.bodyMd.copy(
|
|
||||||
fontSize = metrics.episodeInfoSize,
|
|
||||||
lineHeight = metrics.episodeInfoSize * 1.3f,
|
|
||||||
),
|
|
||||||
color = Color.White.copy(alpha = 0.9f),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = streamTitle,
|
text = title,
|
||||||
style = typeScale.labelSm.copy(
|
style = typeScale.titleLg.copy(
|
||||||
fontSize = metrics.metadataSize,
|
fontSize = metrics.titleSize,
|
||||||
lineHeight = metrics.metadataSize * 1.25f,
|
lineHeight = metrics.titleSize * 1.16f,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
),
|
),
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
color = Color.White,
|
||||||
maxLines = 1,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = providerName,
|
|
||||||
style = typeScale.labelSm.copy(
|
|
||||||
fontSize = metrics.metadataSize,
|
|
||||||
lineHeight = metrics.metadataSize * 1.25f,
|
|
||||||
fontStyle = FontStyle.Italic,
|
|
||||||
),
|
|
||||||
color = Color.White.copy(alpha = 0.7f),
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
Res.string.compose_player_episode_title_format,
|
||||||
|
seasonNumber,
|
||||||
|
episodeNumber,
|
||||||
|
episodeTitle,
|
||||||
|
),
|
||||||
|
style = typeScale.bodyMd.copy(
|
||||||
|
fontSize = metrics.episodeInfoSize,
|
||||||
|
lineHeight = metrics.episodeInfoSize * 1.3f,
|
||||||
|
),
|
||||||
|
color = Color.White.copy(alpha = 0.9f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = streamTitle,
|
||||||
|
style = typeScale.labelSm.copy(
|
||||||
|
fontSize = metrics.metadataSize,
|
||||||
|
lineHeight = metrics.metadataSize * 1.25f,
|
||||||
|
),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = providerName,
|
||||||
|
style = typeScale.labelSm.copy(
|
||||||
|
fontSize = metrics.metadataSize,
|
||||||
|
lineHeight = metrics.metadataSize * 1.25f,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
),
|
||||||
|
color = Color.White.copy(alpha = 0.7f),
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
ParentalGuideOverlay(
|
||||||
|
warnings = parentalWarnings,
|
||||||
|
isVisible = showParentalGuide,
|
||||||
|
onAnimationComplete = onParentalGuideAnimationComplete,
|
||||||
|
contentPadding = PaddingValues(0.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
if (showActions) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
if (onSubmitIntroClick != null) {
|
) {
|
||||||
|
if (onSubmitIntroClick != null) {
|
||||||
|
PlayerHeaderIconButton(
|
||||||
|
icon = Icons.Rounded.Flag,
|
||||||
|
contentDescription = "Submit Intro",
|
||||||
|
buttonSize = metrics.headerIconSize + 16.dp,
|
||||||
|
iconSize = metrics.headerIconSize,
|
||||||
|
onClick = onSubmitIntroClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
PlayerHeaderIconButton(
|
PlayerHeaderIconButton(
|
||||||
icon = Icons.Rounded.Flag,
|
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
||||||
contentDescription = "Submit Intro",
|
contentDescription = if (isLocked) {
|
||||||
|
stringResource(Res.string.compose_player_unlock_controls)
|
||||||
|
} else {
|
||||||
|
stringResource(Res.string.compose_player_lock_controls)
|
||||||
|
},
|
||||||
buttonSize = metrics.headerIconSize + 16.dp,
|
buttonSize = metrics.headerIconSize + 16.dp,
|
||||||
iconSize = metrics.headerIconSize,
|
iconSize = metrics.headerIconSize,
|
||||||
onClick = onSubmitIntroClick,
|
onClick = onLockToggle,
|
||||||
|
)
|
||||||
|
NuvioBackButton(
|
||||||
|
onClick = onBack,
|
||||||
|
containerColor = Color.Black.copy(alpha = 0.35f),
|
||||||
|
contentColor = Color.White,
|
||||||
|
buttonSize = metrics.headerIconSize + 16.dp,
|
||||||
|
iconSize = metrics.headerIconSize,
|
||||||
|
contentDescription = stringResource(Res.string.compose_player_close),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
PlayerHeaderIconButton(
|
|
||||||
icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
|
|
||||||
contentDescription = if (isLocked) {
|
|
||||||
stringResource(Res.string.compose_player_unlock_controls)
|
|
||||||
} else {
|
|
||||||
stringResource(Res.string.compose_player_lock_controls)
|
|
||||||
},
|
|
||||||
buttonSize = metrics.headerIconSize + 16.dp,
|
|
||||||
iconSize = metrics.headerIconSize,
|
|
||||||
onClick = onLockToggle,
|
|
||||||
)
|
|
||||||
NuvioBackButton(
|
|
||||||
onClick = onBack,
|
|
||||||
containerColor = Color.Black.copy(alpha = 0.35f),
|
|
||||||
contentColor = Color.White,
|
|
||||||
buttonSize = metrics.headerIconSize + 16.dp,
|
|
||||||
iconSize = metrics.headerIconSize,
|
|
||||||
contentDescription = stringResource(Res.string.compose_player_close),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ import com.nuvio.app.features.streams.StreamAutoPlaySelector
|
||||||
import com.nuvio.app.features.streams.StreamItem
|
import com.nuvio.app.features.streams.StreamItem
|
||||||
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
import com.nuvio.app.features.streams.StreamLinkCacheRepository
|
||||||
import com.nuvio.app.features.streams.StreamsUiState
|
import com.nuvio.app.features.streams.StreamsUiState
|
||||||
|
import com.nuvio.app.features.tmdb.TmdbService
|
||||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
||||||
import com.nuvio.app.features.watched.WatchedRepository
|
import com.nuvio.app.features.watched.WatchedRepository
|
||||||
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
import com.nuvio.app.features.watchprogress.WatchProgressClock
|
||||||
|
|
@ -181,6 +182,16 @@ fun PlayerScreen(
|
||||||
val downloadedLabel = stringResource(Res.string.compose_player_downloaded)
|
val downloadedLabel = stringResource(Res.string.compose_player_downloaded)
|
||||||
val airsPrefix = stringResource(Res.string.compose_player_airs_prefix)
|
val airsPrefix = stringResource(Res.string.compose_player_airs_prefix)
|
||||||
val tbaLabel = stringResource(Res.string.compose_player_tba)
|
val tbaLabel = stringResource(Res.string.compose_player_tba)
|
||||||
|
val parentalGuideLabels = ParentalGuideLabels(
|
||||||
|
nudity = stringResource(Res.string.parental_nudity),
|
||||||
|
violence = stringResource(Res.string.parental_violence),
|
||||||
|
profanity = stringResource(Res.string.parental_profanity),
|
||||||
|
alcohol = stringResource(Res.string.parental_alcohol),
|
||||||
|
frightening = stringResource(Res.string.parental_frightening),
|
||||||
|
severe = stringResource(Res.string.parental_severity_severe),
|
||||||
|
moderate = stringResource(Res.string.parental_severity_moderate),
|
||||||
|
mild = stringResource(Res.string.parental_severity_mild),
|
||||||
|
)
|
||||||
val gestureController = rememberPlayerGestureController()
|
val gestureController = rememberPlayerGestureController()
|
||||||
var controlsVisible by rememberSaveable { mutableStateOf(true) }
|
var controlsVisible by rememberSaveable { mutableStateOf(true) }
|
||||||
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
|
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
@ -282,6 +293,12 @@ fun PlayerScreen(
|
||||||
var activeSkipInterval by remember { mutableStateOf<SkipInterval?>(null) }
|
var activeSkipInterval by remember { mutableStateOf<SkipInterval?>(null) }
|
||||||
var skipIntervalDismissed by remember { mutableStateOf(false) }
|
var skipIntervalDismissed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
// Parental guide overlay state
|
||||||
|
var parentalWarnings by remember { mutableStateOf<List<ParentalWarning>>(emptyList()) }
|
||||||
|
var showParentalGuide by remember { mutableStateOf(false) }
|
||||||
|
var parentalGuideHasShown by remember { mutableStateOf(false) }
|
||||||
|
var playbackStartedForParentalGuide by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// Next episode state
|
// Next episode state
|
||||||
var nextEpisodeInfo by remember { mutableStateOf<NextEpisodeInfo?>(null) }
|
var nextEpisodeInfo by remember { mutableStateOf<NextEpisodeInfo?>(null) }
|
||||||
var showNextEpisodeCard by remember { mutableStateOf(false) }
|
var showNextEpisodeCard by remember { mutableStateOf(false) }
|
||||||
|
|
@ -418,6 +435,25 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun tryShowParentalGuide() {
|
||||||
|
if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) {
|
||||||
|
playbackStartedForParentalGuide = true
|
||||||
|
controlsVisible = true
|
||||||
|
showParentalGuide = true
|
||||||
|
parentalGuideHasShown = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolveParentalGuideImdbId(): String? {
|
||||||
|
val candidates = listOf(parentMetaId, activeVideoId)
|
||||||
|
candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it }
|
||||||
|
val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null
|
||||||
|
return TmdbService.tmdbToImdb(
|
||||||
|
tmdbId = tmdbId,
|
||||||
|
mediaType = contentType ?: parentMetaType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun flushWatchProgress() {
|
fun flushWatchProgress() {
|
||||||
emitStopScrobbleForCurrentProgress()
|
emitStopScrobbleForCurrentProgress()
|
||||||
WatchProgressRepository.flushPlaybackProgress(
|
WatchProgressRepository.flushPlaybackProgress(
|
||||||
|
|
@ -1289,8 +1325,8 @@ fun PlayerScreen(
|
||||||
initialSeekApplied = true
|
initialSeekApplied = true
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, errorMessage) {
|
LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, showParentalGuide, errorMessage) {
|
||||||
if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || errorMessage != null) {
|
if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || showParentalGuide || errorMessage != null) {
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
delay(3500)
|
delay(3500)
|
||||||
|
|
@ -1354,6 +1390,28 @@ fun PlayerScreen(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch parental guide when the playable item changes.
|
||||||
|
LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) {
|
||||||
|
parentalWarnings = emptyList()
|
||||||
|
showParentalGuide = false
|
||||||
|
parentalGuideHasShown = false
|
||||||
|
playbackStartedForParentalGuide = false
|
||||||
|
|
||||||
|
val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect
|
||||||
|
val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect
|
||||||
|
parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels)
|
||||||
|
|
||||||
|
if (playbackSnapshot.isPlaying) {
|
||||||
|
tryShowParentalGuide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) {
|
||||||
|
if (playbackSnapshot.isPlaying) {
|
||||||
|
tryShowParentalGuide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch skip intervals when episode changes
|
// Fetch skip intervals when episode changes
|
||||||
LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) {
|
LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) {
|
||||||
skipIntervals = emptyList()
|
skipIntervals = emptyList()
|
||||||
|
|
@ -1692,7 +1750,7 @@ fun PlayerScreen(
|
||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = controlsVisible && !playerControlsLocked,
|
visible = (controlsVisible || showParentalGuide) && !playerControlsLocked,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut(),
|
||||||
) {
|
) {
|
||||||
|
|
@ -1708,6 +1766,7 @@ fun PlayerScreen(
|
||||||
metrics = metrics,
|
metrics = metrics,
|
||||||
resizeMode = resizeMode,
|
resizeMode = resizeMode,
|
||||||
isLocked = playerControlsLocked,
|
isLocked = playerControlsLocked,
|
||||||
|
showPlaybackControls = controlsVisible,
|
||||||
onLockToggle = {
|
onLockToggle = {
|
||||||
if (playerControlsLocked) {
|
if (playerControlsLocked) {
|
||||||
unlockPlayerControls()
|
unlockPlayerControls()
|
||||||
|
|
@ -1732,6 +1791,9 @@ fun PlayerScreen(
|
||||||
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
|
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
|
||||||
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
|
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null,
|
||||||
onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
|
onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null,
|
||||||
|
parentalWarnings = parentalWarnings,
|
||||||
|
showParentalGuide = showParentalGuide,
|
||||||
|
onParentalGuideAnimationComplete = { showParentalGuide = false },
|
||||||
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
onScrubChange = { positionMs -> scrubbingPositionMs = positionMs },
|
||||||
onScrubFinished = { positionMs ->
|
onScrubFinished = { positionMs ->
|
||||||
scrubbingPositionMs = null
|
scrubbingPositionMs = null
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
package com.nuvio.app.features.player
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class ParentalGuideRepositoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dominant severity ignores none when none has more votes`() {
|
||||||
|
val category = ImdbApiParentsGuideCategory(
|
||||||
|
category = "VIOLENCE",
|
||||||
|
severityBreakdowns = listOf(
|
||||||
|
ImdbApiSeverityBreakdown("none", 12),
|
||||||
|
ImdbApiSeverityBreakdown("mild", 7),
|
||||||
|
ImdbApiSeverityBreakdown("moderate", 3),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(resolveParentalGuideSeverity(category))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `dominant severity chooses highest voted non-none severity`() {
|
||||||
|
val category = ImdbApiParentsGuideCategory(
|
||||||
|
category = "VIOLENCE",
|
||||||
|
severityBreakdowns = listOf(
|
||||||
|
ImdbApiSeverityBreakdown("none", 2),
|
||||||
|
ImdbApiSeverityBreakdown("mild", 5),
|
||||||
|
ImdbApiSeverityBreakdown("severe", 9),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals("severe", resolveParentalGuideSeverity(category))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `warnings are sorted by severity and localized`() {
|
||||||
|
val warnings = buildParentalWarnings(
|
||||||
|
guide = ParentalGuideResult(
|
||||||
|
nudity = "mild",
|
||||||
|
violence = "severe",
|
||||||
|
profanity = "moderate",
|
||||||
|
),
|
||||||
|
labels = ParentalGuideLabels(
|
||||||
|
nudity = "Nudity",
|
||||||
|
violence = "Violence",
|
||||||
|
profanity = "Profanity",
|
||||||
|
alcohol = "Alcohol/Drugs",
|
||||||
|
frightening = "Frightening",
|
||||||
|
severe = "Severe",
|
||||||
|
moderate = "Moderate",
|
||||||
|
mild = "Mild",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf("Violence", "Profanity", "Nudity"), warnings.map { it.label })
|
||||||
|
assertEquals(listOf("Severe", "Moderate", "Mild"), warnings.map { it.severity })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ids are extracted from common player id shapes`() {
|
||||||
|
assertEquals("tt15398776", extractParentalGuideImdbId("tt15398776:1:2"))
|
||||||
|
assertEquals("tt15398776", extractParentalGuideImdbId("series:tt15398776:1:2"))
|
||||||
|
assertEquals(12345, extractParentalGuideTmdbId("tmdb:12345"))
|
||||||
|
assertEquals(12345, extractParentalGuideTmdbId("series:tmdb:12345"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue