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