mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-19 08:22:00 +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_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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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