From 1df74ea0fe94a1f1d6495989c14127811037385a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 20:16:48 +0530 Subject: [PATCH] faet: parental guilde overlay --- .../composeResources/values/strings.xml | 8 + .../features/player/ParentalGuideOverlay.kt | 141 ++++++++++ .../player/ParentalGuideRepository.kt | 175 ++++++++++++ .../app/features/player/PlayerControls.kt | 249 ++++++++++-------- .../nuvio/app/features/player/PlayerScreen.kt | 68 ++++- .../player/ParentalGuideRepositoryTest.kt | 68 +++++ 6 files changed, 600 insertions(+), 109 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3c21777b..14b54129 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1312,6 +1312,14 @@ A new episode is out now %1$s is out now Episode Releases + Alcohol/Drugs + Frightening + Nudity + Profanity + Mild + Moderate + Severe + Violence Creator Director Writer diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt new file mode 100644 index 00000000..9cf5fb6a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt @@ -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, + 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), + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt new file mode 100644 index 00000000..06fe2fb5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt @@ -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() + 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(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, +): 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 { + 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 = emptyList(), +) + +@Serializable +internal data class ImdbApiParentsGuideCategory( + @SerialName("category") val category: String = "", + @SerialName("severityBreakdowns") val severityBreakdowns: List? = null, +) + +@Serializable +internal data class ImdbApiSeverityBreakdown( + @SerialName("severityLevel") val severityLevel: String = "", + @SerialName("voteCount") val voteCount: Int = 0, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 13f975ed..540ed57b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -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 = 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, + 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), - ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 279aae9f..fa29d9db 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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(null) } var skipIntervalDismissed by remember { mutableStateOf(false) } + // Parental guide overlay state + var parentalWarnings by remember { mutableStateOf>(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(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 diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt new file mode 100644 index 00000000..e247a54d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt @@ -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")) + } +}