faet: parental guilde overlay

This commit is contained in:
tapframe 2026-05-16 20:16:48 +05:30
parent ac48ab11b2
commit 1df74ea0fe
6 changed files with 600 additions and 109 deletions

View file

@ -1312,6 +1312,14 @@
<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_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_director">Director</string>
<string name="person_role_writer">Writer</string>

View file

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

View file

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

View file

@ -1,11 +1,14 @@
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.border
import androidx.compose.foundation.clickable
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
@ -37,6 +40,7 @@ import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -69,6 +73,7 @@ internal fun PlayerControlsShell(
metrics: PlayerLayoutMetrics,
resizeMode: PlayerResizeMode,
isLocked: Boolean,
showPlaybackControls: Boolean = true,
onLockToggle: () -> Unit,
onBack: () -> Unit,
onTogglePlayback: () -> Unit,
@ -81,6 +86,9 @@ internal fun PlayerControlsShell(
onSourcesClick: (() -> Unit)? = null,
onEpisodesClick: (() -> Unit)? = null,
onSubmitIntroClick: (() -> Unit)? = null,
parentalWarnings: List<ParentalWarning> = emptyList(),
showParentalGuide: Boolean = false,
onParentalGuideAnimationComplete: () -> Unit = {},
onScrubChange: (Long) -> Unit,
onScrubFinished: (Long) -> Unit,
horizontalSafePadding: androidx.compose.ui.unit.Dp,
@ -131,7 +139,11 @@ internal fun PlayerControlsShell(
episodeTitle = episodeTitle,
metrics = metrics,
isLocked = isLocked,
showActions = showPlaybackControls,
onSubmitIntroClick = onSubmitIntroClick,
parentalWarnings = parentalWarnings,
showParentalGuide = showParentalGuide,
onParentalGuideAnimationComplete = onParentalGuideAnimationComplete,
onLockToggle = onLockToggle,
onBack = onBack,
modifier = Modifier
@ -145,36 +157,40 @@ internal fun PlayerControlsShell(
),
)
CenterControls(
snapshot = playbackSnapshot,
metrics = metrics,
onSeekBack = onSeekBack,
onSeekForward = onSeekForward,
onTogglePlayback = onTogglePlayback,
modifier = Modifier
.align(Alignment.Center)
.padding(bottom = metrics.centerLift),
)
if (showPlaybackControls) {
CenterControls(
snapshot = playbackSnapshot,
metrics = metrics,
onSeekBack = onSeekBack,
onSeekForward = onSeekForward,
onTogglePlayback = onTogglePlayback,
modifier = Modifier
.align(Alignment.Center)
.padding(bottom = metrics.centerLift),
)
}
ProgressControls(
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
resizeMode = resizeMode,
onScrubChange = onScrubChange,
onScrubFinished = onScrubFinished,
onResizeModeClick = onResizeModeClick,
onSpeedClick = onSpeedClick,
onSubtitleClick = onSubtitleClick,
onAudioClick = onAudioClick,
onSourcesClick = onSourcesClick,
onEpisodesClick = onEpisodesClick,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = metrics.horizontalPadding)
.padding(bottom = metrics.sliderBottomOffset),
)
if (showPlaybackControls) {
ProgressControls(
playbackSnapshot = playbackSnapshot,
displayedPositionMs = displayedPositionMs,
metrics = metrics,
resizeMode = resizeMode,
onScrubChange = onScrubChange,
onScrubFinished = onScrubFinished,
onResizeModeClick = onResizeModeClick,
onSpeedClick = onSpeedClick,
onSubtitleClick = onSubtitleClick,
onAudioClick = onAudioClick,
onSourcesClick = onSourcesClick,
onEpisodesClick = onEpisodesClick,
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.padding(horizontal = metrics.horizontalPadding)
.padding(bottom = metrics.sliderBottomOffset),
)
}
}
}
}
@ -189,110 +205,131 @@ private fun PlayerHeader(
episodeTitle: String?,
metrics: PlayerLayoutMetrics,
isLocked: Boolean,
showActions: Boolean,
onSubmitIntroClick: (() -> Unit)?,
parentalWarnings: List<ParentalWarning>,
showParentalGuide: Boolean,
onParentalGuideAnimationComplete: () -> Unit,
onLockToggle: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
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) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top,
) {
Column(
Box(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = title,
style = typeScale.titleLg.copy(
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,
Column(
modifier = Modifier.graphicsLayer { alpha = metadataAlpha },
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = streamTitle,
style = typeScale.labelSm.copy(
fontSize = metrics.metadataSize,
lineHeight = metrics.metadataSize * 1.25f,
text = title,
style = typeScale.titleLg.copy(
fontSize = metrics.titleSize,
lineHeight = metrics.titleSize * 1.16f,
fontWeight = FontWeight.Bold,
),
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,
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 = 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(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (onSubmitIntroClick != null) {
if (showActions) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (onSubmitIntroClick != null) {
PlayerHeaderIconButton(
icon = Icons.Rounded.Flag,
contentDescription = "Submit Intro",
buttonSize = metrics.headerIconSize + 16.dp,
iconSize = metrics.headerIconSize,
onClick = onSubmitIntroClick,
)
}
PlayerHeaderIconButton(
icon = Icons.Rounded.Flag,
contentDescription = "Submit Intro",
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 = 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),
)
}
}
}

View file

@ -61,6 +61,7 @@ import com.nuvio.app.features.streams.StreamAutoPlaySelector
import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository
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.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.WatchProgressClock
@ -181,6 +182,16 @@ fun PlayerScreen(
val downloadedLabel = stringResource(Res.string.compose_player_downloaded)
val airsPrefix = stringResource(Res.string.compose_player_airs_prefix)
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()
var controlsVisible by rememberSaveable { mutableStateOf(true) }
var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
@ -282,6 +293,12 @@ fun PlayerScreen(
var activeSkipInterval by remember { mutableStateOf<SkipInterval?>(null) }
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
var nextEpisodeInfo by remember { mutableStateOf<NextEpisodeInfo?>(null) }
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() {
emitStopScrobbleForCurrentProgress()
WatchProgressRepository.flushPlaybackProgress(
@ -1289,8 +1325,8 @@ fun PlayerScreen(
initialSeekApplied = true
}
LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, errorMessage) {
if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || errorMessage != null) {
LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, showParentalGuide, errorMessage) {
if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || showParentalGuide || errorMessage != null) {
return@LaunchedEffect
}
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
LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) {
skipIntervals = emptyList()
@ -1692,7 +1750,7 @@ fun PlayerScreen(
}
AnimatedVisibility(
visible = controlsVisible && !playerControlsLocked,
visible = (controlsVisible || showParentalGuide) && !playerControlsLocked,
enter = fadeIn(),
exit = fadeOut(),
) {
@ -1708,6 +1766,7 @@ fun PlayerScreen(
metrics = metrics,
resizeMode = resizeMode,
isLocked = playerControlsLocked,
showPlaybackControls = controlsVisible,
onLockToggle = {
if (playerControlsLocked) {
unlockPlayerControls()
@ -1732,6 +1791,9 @@ fun PlayerScreen(
onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null,
onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } 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 },
onScrubFinished = { positionMs ->
scrubbingPositionMs = null

View file

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