mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge 401f160720 into 755f6ff9b8
This commit is contained in:
commit
0db78d3a1d
10 changed files with 917 additions and 8 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,6 +22,9 @@ node_modules/
|
|||
logs/
|
||||
analysis/
|
||||
Docs
|
||||
!Docs/
|
||||
Docs/*
|
||||
!Docs/NUVIO_PULL_REQUEST.md
|
||||
keystore/
|
||||
scripts/build-distribution.sh
|
||||
asset
|
||||
|
|
|
|||
76
Docs/NUVIO_PULL_REQUEST.md
Normal file
76
Docs/NUVIO_PULL_REQUEST.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
## Summary
|
||||
|
||||
Draft implementation of a native, localized Parents Guide accordion on movie and series details. It uses a separately hosted, open-source metadata-only API and does not change streams, playback, or source selection.
|
||||
|
||||
## PR type
|
||||
|
||||
- [ ] Reproducible bug fix
|
||||
- [ ] UI glitch/bug fix
|
||||
- [ ] Behavior bug/regression fix
|
||||
- [ ] Small maintenance only, with no UI or behavior change
|
||||
- [ ] Docs accuracy fix
|
||||
- [ ] Translation/localization only
|
||||
- [ ] Approved larger or directional change
|
||||
|
||||
This remains unchecked while the feature request is awaiting explicit maintainer approval.
|
||||
|
||||
## Why
|
||||
|
||||
Families cannot currently review sourced category summaries or scene-level parental advisories before playback. The proposed section covers sex and nudity, violence and gore, profanity, alcohol/drugs/smoking, and frightening/intense scenes without affecting the normal details flow when data is unavailable.
|
||||
|
||||
## Issue or approval
|
||||
|
||||
Awaiting a maintainer decision in #1601. This PR is intentionally a draft and is not submitted as review-ready before approval.
|
||||
|
||||
## UI / behavior impact
|
||||
|
||||
- [ ] No UI change
|
||||
- [ ] No behavior change
|
||||
- [ ] UI changed only to fix a documented glitch/bug
|
||||
- [ ] Behavior changed only to fix a documented bug/regression
|
||||
- [ ] UI change has explicit maintainer approval
|
||||
- [ ] Behavior change has explicit maintainer approval
|
||||
|
||||
The implementation adds UI and network/cache behavior. The approval boxes will remain unchecked unless maintainers approve #1601.
|
||||
|
||||
## Policy check
|
||||
|
||||
- [x] I have read and understood `CONTRIBUTING.md`.
|
||||
- [x] This PR is small, focused, and limited to one problem.
|
||||
- [x] This PR is not cosmetic-only.
|
||||
- [ ] Any UI change fixes a linked glitch/bug and includes visual proof, or this PR has no UI change.
|
||||
- [ ] Any behavior change fixes a linked bug/regression or has explicit approval, or this PR has no behavior change.
|
||||
- [x] This PR does not bundle unrelated refactors, cleanups, formatting, or drive-by changes.
|
||||
- [x] This PR does not add dependencies, architecture changes, migrations, or product-direction changes without explicit approval.
|
||||
- [x] I listed the testing performed below.
|
||||
|
||||
The approval-dependent checks will be completed only after explicit approval and visual proof.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
- No streaming resource, torrent behavior, playback-source behavior, or navigation redesign.
|
||||
- Sends only the current title identifiers, media type, optional season/episode, and language.
|
||||
- API errors are isolated from metadata loading and the rest of the details page.
|
||||
- Uses the existing Show Parental Guide preference as the master switch.
|
||||
- Initial cache is process-local; persistent settings/cache are outside this draft.
|
||||
|
||||
## Testing
|
||||
|
||||
- Backend: formatting, lint, strict type-check, 12 Vitest tests, production build, Vercel deployment, Neon readiness, and external endpoint smoke tests passed.
|
||||
- Mobile: `./gradlew :composeApp:compileKotlinIosSimulatorArm64` passed after the production endpoint was configured.
|
||||
- iOS tests compiled and linked, but simulator execution could not complete because the local simulator service hung.
|
||||
- Android was not verified because the Android SDK is unavailable in the current environment.
|
||||
|
||||
Added mobile tests cover JSON/status/provenance/timestamps, partial success, network error isolation, cache hit/expiry, identifier resolution, category ordering/severity, spoiler filtering, and timestamp formatting.
|
||||
|
||||
## Screenshots / Video (UI changes only)
|
||||
|
||||
Pending feature approval and access to a working simulator/device. Before review, this draft needs collapsed, expanded-category, scene-detail, partial, unavailable, error, dark/light, large-text, and RTL captures.
|
||||
|
||||
## Breaking changes
|
||||
|
||||
None. With the feature disabled, no request or section is added. Missing guides and API failures leave the normal details page functional.
|
||||
|
||||
## Linked issues
|
||||
|
||||
#1601 — awaiting explicit maintainer approval.
|
||||
|
|
@ -43,6 +43,9 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
@get:Input
|
||||
abstract val realtimeSyncEnabled: Property<Boolean>
|
||||
|
||||
@get:Input
|
||||
abstract val parentsGuideApiBaseUrl: Property<String>
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val props = Properties()
|
||||
|
|
@ -176,6 +179,19 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/features/parentsguide").apply {
|
||||
mkdirs()
|
||||
resolve("ParentsGuideConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.features.parentsguide
|
||||
|
|
||||
|object ParentsGuideConfig {
|
||||
| const val API_BASE_URL = "${parentsGuideApiBaseUrl.get()}"
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,6 +317,12 @@ val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generat
|
|||
}
|
||||
)
|
||||
realtimeSyncEnabled.set(runtimeConfigBoolean("NUVIO_REALTIME_SYNC_ENABLED", true))
|
||||
parentsGuideApiBaseUrl.set(
|
||||
runtimeConfigValue(
|
||||
"PARENTS_GUIDE_API_BASE_URL",
|
||||
"https://nuvio-parents-guide-addon.vercel.app",
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompilationTask<*>>().configureEach {
|
||||
|
|
|
|||
|
|
@ -2058,4 +2058,34 @@
|
|||
<item quantity="one">%d title</item>
|
||||
<item quantity="other">%d titles</item>
|
||||
</plurals>
|
||||
<!-- Parents Guide details integration -->
|
||||
<string name="parents_guide_title">Parents Guide</string>
|
||||
<string name="parents_guide_sex_nudity">Sex & Nudity</string>
|
||||
<string name="parents_guide_violence_gore">Violence & Gore</string>
|
||||
<string name="parents_guide_profanity">Profanity</string>
|
||||
<string name="parents_guide_alcohol_drugs_smoking">Alcohol, Drugs & Smoking</string>
|
||||
<string name="parents_guide_frightening_intense">Frightening & Intense Scenes</string>
|
||||
<string name="parents_guide_severity_none">None</string>
|
||||
<string name="parents_guide_severity_mild">Mild</string>
|
||||
<string name="parents_guide_severity_moderate">Moderate</string>
|
||||
<string name="parents_guide_severity_severe">Severe</string>
|
||||
<string name="parents_guide_severity_unknown">Unknown</string>
|
||||
<string name="parents_guide_show_scenes">Show scenes</string>
|
||||
<string name="parents_guide_hide_scenes">Hide scenes</string>
|
||||
<string name="parents_guide_hide_spoilers">Hide spoilers</string>
|
||||
<string name="parents_guide_show_spoilers">Show spoilers</string>
|
||||
<string name="parents_guide_spoiler_hidden">Scene description hidden to avoid spoilers.</string>
|
||||
<string name="parents_guide_unavailable">No detailed Parents Guide is available for this title yet.</string>
|
||||
<string name="parents_guide_partial">Available information may be incomplete.</string>
|
||||
<string name="parents_guide_series_fallback">Showing general guidance for this series.</string>
|
||||
<string name="parents_guide_contribute">Contribute a guide</string>
|
||||
<string name="parents_guide_retry">Retry</string>
|
||||
<string name="parents_guide_source">Source</string>
|
||||
<string name="parents_guide_verified">Verified</string>
|
||||
<string name="parents_guide_community_submitted">Community submitted</string>
|
||||
<string name="parents_guide_timestamp_disclaimer">Timestamp may vary by release version.</string>
|
||||
<string name="parents_guide_loading">Loading Parents Guide…</string>
|
||||
<string name="parents_guide_error">Parents Guide could not be loaded.</string>
|
||||
<string name="parents_guide_expand">Expand Parents Guide</string>
|
||||
<string name="parents_guide_collapse">Collapse Parents Guide</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ import com.nuvio.app.features.home.MetaPreview
|
|||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.toLibraryItem
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.parentsguide.ParentsGuideConfig
|
||||
import com.nuvio.app.features.parentsguide.ParentsGuideRepository
|
||||
import com.nuvio.app.features.parentsguide.ParentsGuideSection
|
||||
import com.nuvio.app.features.parentsguide.ParentsGuideUiState
|
||||
import com.nuvio.app.features.parentsguide.resolveParentsGuideRequest
|
||||
import com.nuvio.app.features.streams.StreamAutoPlayPolicy
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
|
|
@ -516,6 +521,21 @@ fun MetaDetailsScreen(
|
|||
meta.trailers.isNotEmpty()
|
||||
}
|
||||
val uriHandler = LocalUriHandler.current
|
||||
var parentsGuideState by remember(meta.id) { mutableStateOf<ParentsGuideUiState>(ParentsGuideUiState.Loading) }
|
||||
var parentsGuideRetryToken by remember(meta.id) { mutableIntStateOf(0) }
|
||||
val parentsGuideRequest = remember(meta.type, meta.id) { resolveParentsGuideRequest(meta.type, meta.id) }
|
||||
LaunchedEffect(meta.id, parentsGuideRetryToken, playerSettingsUiState.showParentalGuide) {
|
||||
if (!playerSettingsUiState.showParentalGuide || parentsGuideRequest == null) {
|
||||
parentsGuideState = ParentsGuideUiState.Unavailable()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
parentsGuideState = ParentsGuideUiState.Loading
|
||||
parentsGuideState = ParentsGuideRepository.load(
|
||||
apiBaseUrl = ParentsGuideConfig.API_BASE_URL,
|
||||
request = parentsGuideRequest,
|
||||
forceRefresh = parentsGuideRetryToken > 0,
|
||||
)
|
||||
}
|
||||
val inAppTrailerPlaybackEnabled = AppFeaturePolicy.trailerPlaybackMode == TrailerPlaybackMode.IN_APP
|
||||
val trailerScope = rememberCoroutineScope()
|
||||
var selectedTrailer by remember(meta.id) { mutableStateOf<MetaTrailer?>(null) }
|
||||
|
|
@ -1005,6 +1025,14 @@ fun MetaDetailsScreen(
|
|||
onCompanyClick = onCompanyClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
showParentsGuide = playerSettingsUiState.showParentalGuide,
|
||||
parentsGuideState = parentsGuideState,
|
||||
onRetryParentsGuide = { parentsGuideRetryToken += 1 },
|
||||
onContributeParentsGuide = {
|
||||
ParentsGuideConfig.API_BASE_URL.takeIf(String::isNotBlank)?.let { baseUrl ->
|
||||
runCatching { uriHandler.openUri("${baseUrl.trimEnd('/')}/configure") }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
item(key = "detail-bottom-spacer") {
|
||||
|
|
@ -1613,6 +1641,10 @@ private fun LazyListScope.configuredMetaSectionItems(
|
|||
onCompanyClick: ((MetaCompany, String) -> Unit)?,
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
||||
showParentsGuide: Boolean,
|
||||
parentsGuideState: ParentsGuideUiState,
|
||||
onRetryParentsGuide: () -> Unit,
|
||||
onContributeParentsGuide: () -> Unit,
|
||||
) {
|
||||
val enabledItems = settings.items.filter { it.enabled }
|
||||
fun sectionHasContent(key: MetaScreenSectionKey): Boolean =
|
||||
|
|
@ -1694,29 +1726,62 @@ private fun LazyListScope.configuredMetaSectionItems(
|
|||
}
|
||||
}
|
||||
|
||||
if (!settings.tabLayout) {
|
||||
enabledItems
|
||||
.filter { sectionHasContent(it.key) }
|
||||
.forEach { section ->
|
||||
addSectionItem(
|
||||
key = "detail-section-${section.key.name}",
|
||||
sectionItems = listOf(section),
|
||||
forceTabLayout = false,
|
||||
fun addParentsGuideItem() {
|
||||
if (!showParentsGuide) return
|
||||
item(key = "detail-parents-guide") {
|
||||
DetailSectionContainer(
|
||||
horizontalPadding = contentHorizontalPadding,
|
||||
contentMaxWidth = contentMaxWidth,
|
||||
) {
|
||||
ParentsGuideSection(
|
||||
state = parentsGuideState,
|
||||
onRetry = onRetryParentsGuide,
|
||||
onContribute = onContributeParentsGuide,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!settings.tabLayout) {
|
||||
var parentsGuideInserted = false
|
||||
enabledItems.filter { sectionHasContent(it.key) }.forEach { section ->
|
||||
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.MORE_LIKE_THIS) {
|
||||
addParentsGuideItem()
|
||||
parentsGuideInserted = true
|
||||
}
|
||||
addSectionItem(
|
||||
key = "detail-section-${section.key.name}",
|
||||
sectionItems = listOf(section),
|
||||
forceTabLayout = false,
|
||||
)
|
||||
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.DETAILS) {
|
||||
addParentsGuideItem()
|
||||
parentsGuideInserted = true
|
||||
}
|
||||
}
|
||||
if (!parentsGuideInserted) addParentsGuideItem()
|
||||
return
|
||||
}
|
||||
|
||||
val processedGroups = mutableSetOf<Int>()
|
||||
var parentsGuideInserted = false
|
||||
enabledItems.forEach { section ->
|
||||
val groupId = section.tabGroup
|
||||
if (groupId == null) {
|
||||
if (sectionHasContent(section.key)) {
|
||||
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.MORE_LIKE_THIS) {
|
||||
addParentsGuideItem()
|
||||
parentsGuideInserted = true
|
||||
}
|
||||
addSectionItem(
|
||||
key = "detail-section-${section.key.name}",
|
||||
sectionItems = listOf(section),
|
||||
forceTabLayout = true,
|
||||
)
|
||||
if (!parentsGuideInserted && section.key == MetaScreenSectionKey.DETAILS) {
|
||||
addParentsGuideItem()
|
||||
parentsGuideInserted = true
|
||||
}
|
||||
}
|
||||
} else if (groupId !in processedGroups) {
|
||||
processedGroups.add(groupId)
|
||||
|
|
@ -1724,14 +1789,23 @@ private fun LazyListScope.configuredMetaSectionItems(
|
|||
item.tabGroup == groupId && sectionHasContent(item.key)
|
||||
}
|
||||
if (groupMembers.isNotEmpty()) {
|
||||
if (!parentsGuideInserted && groupMembers.any { it.key == MetaScreenSectionKey.MORE_LIKE_THIS }) {
|
||||
addParentsGuideItem()
|
||||
parentsGuideInserted = true
|
||||
}
|
||||
addSectionItem(
|
||||
key = "detail-section-group-$groupId",
|
||||
sectionItems = groupMembers,
|
||||
forceTabLayout = groupMembers.size > 1,
|
||||
)
|
||||
if (!parentsGuideInserted && groupMembers.any { it.key == MetaScreenSectionKey.DETAILS }) {
|
||||
addParentsGuideItem()
|
||||
parentsGuideInserted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!parentsGuideInserted) addParentsGuideItem()
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
package com.nuvio.app.features.parentsguide
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ParentsGuideEnvelope(
|
||||
val success: Boolean,
|
||||
val data: ParentsGuideData,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ParentsGuideData(
|
||||
val identity: ParentsGuideIdentity,
|
||||
val guide: ParentsGuide,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ParentsGuideIdentity(
|
||||
val mediaType: String,
|
||||
val title: String = "",
|
||||
val releaseYear: Int? = null,
|
||||
val imdbId: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
val stremioId: String? = null,
|
||||
val seasonNumber: Int? = null,
|
||||
val episodeNumber: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ParentsGuide(
|
||||
val id: String,
|
||||
val overallStatus: ParentsGuideStatus,
|
||||
val overallSummary: String? = null,
|
||||
val minimumSuggestedAge: Int? = null,
|
||||
val editionLabel: String? = null,
|
||||
val contentVersion: Int = 1,
|
||||
val categories: List<ParentsGuideCategory> = emptyList(),
|
||||
val sources: List<ParentsGuideSource> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class ParentsGuideStatus {
|
||||
@SerialName("available") AVAILABLE,
|
||||
@SerialName("partial") PARTIAL,
|
||||
@SerialName("unavailable") UNAVAILABLE,
|
||||
@SerialName("disputed") DISPUTED,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ParentsGuideCategory(
|
||||
val category: ParentsGuideCategoryType,
|
||||
val label: String = "",
|
||||
val severity: ParentsGuideSeverity,
|
||||
val summary: String? = null,
|
||||
val spoilerLevel: ParentsGuideSpoilerLevel = ParentsGuideSpoilerLevel.NONE,
|
||||
val scenes: List<ParentsGuideScene> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class ParentsGuideCategoryType {
|
||||
@SerialName("sex_nudity") SEX_NUDITY,
|
||||
@SerialName("violence_gore") VIOLENCE_GORE,
|
||||
@SerialName("profanity") PROFANITY,
|
||||
@SerialName("alcohol_drugs_smoking") ALCOHOL_DRUGS_SMOKING,
|
||||
@SerialName("frightening_intense") FRIGHTENING_INTENSE,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ParentsGuideSeverity {
|
||||
@SerialName("none") NONE,
|
||||
@SerialName("mild") MILD,
|
||||
@SerialName("moderate") MODERATE,
|
||||
@SerialName("severe") SEVERE,
|
||||
@SerialName("unknown") UNKNOWN,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ParentsGuideSpoilerLevel {
|
||||
@SerialName("none") NONE,
|
||||
@SerialName("minor") MINOR,
|
||||
@SerialName("major") MAJOR,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ParentsGuideScene(
|
||||
val description: String,
|
||||
val startSeconds: Int? = null,
|
||||
val endSeconds: Int? = null,
|
||||
val seasonNumber: Int? = null,
|
||||
val episodeNumber: Int? = null,
|
||||
val spoilerLevel: ParentsGuideSpoilerLevel = ParentsGuideSpoilerLevel.NONE,
|
||||
val verificationStatus: String = "unverified",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ParentsGuideSource(
|
||||
val sourceType: String,
|
||||
val sourceName: String,
|
||||
val sourceUrl: String? = null,
|
||||
val sourceLicense: String? = null,
|
||||
val attributionText: String? = null,
|
||||
)
|
||||
|
||||
data class ParentsGuideRequest(
|
||||
val mediaType: String,
|
||||
val imdbId: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
val stremioId: String? = null,
|
||||
val season: Int? = null,
|
||||
val episode: Int? = null,
|
||||
)
|
||||
|
||||
sealed interface ParentsGuideUiState {
|
||||
data object Loading : ParentsGuideUiState
|
||||
data class Available(val data: ParentsGuideData, val fromCache: Boolean = false, val isSeriesFallback: Boolean = false) : ParentsGuideUiState
|
||||
data class Unavailable(val fromCache: Boolean = false) : ParentsGuideUiState
|
||||
data class Error(val hasCachedData: Boolean = false) : ParentsGuideUiState
|
||||
}
|
||||
|
||||
internal val categoryOrder = ParentsGuideCategoryType.entries.withIndex().associate { it.value to it.index }
|
||||
|
||||
internal fun orderedCategories(categories: List<ParentsGuideCategory>): List<ParentsGuideCategory> =
|
||||
categories.sortedBy { categoryOrder[it.category] ?: Int.MAX_VALUE }
|
||||
|
||||
internal fun visibleScenes(scenes: List<ParentsGuideScene>, showSpoilers: Boolean): List<ParentsGuideScene> =
|
||||
scenes.filter { showSpoilers || it.spoilerLevel == ParentsGuideSpoilerLevel.NONE }
|
||||
|
||||
internal fun formatGuideTimestamp(seconds: Int): String {
|
||||
val hours = seconds / 3600
|
||||
val minutes = (seconds % 3600) / 60
|
||||
val remainder = seconds % 60
|
||||
return if (hours > 0) {
|
||||
"${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}"
|
||||
} else {
|
||||
"${minutes.toString().padStart(2, '0')}:${remainder.toString().padStart(2, '0')}"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
package com.nuvio.app.features.parentsguide
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
private val previewData = ParentsGuideData(
|
||||
identity = ParentsGuideIdentity("movie", "Fictional preview"),
|
||||
guide = ParentsGuide(
|
||||
id = "preview",
|
||||
overallStatus = ParentsGuideStatus.PARTIAL,
|
||||
overallSummary = "Preview-only information with several content advisories.",
|
||||
categories = listOf(
|
||||
ParentsGuideCategory(
|
||||
ParentsGuideCategoryType.SEX_NUDITY,
|
||||
severity = ParentsGuideSeverity.MODERATE,
|
||||
scenes = listOf(ParentsGuideScene("A concise preview description.", spoilerLevel = ParentsGuideSpoilerLevel.NONE)),
|
||||
),
|
||||
ParentsGuideCategory(
|
||||
ParentsGuideCategoryType.VIOLENCE_GORE,
|
||||
severity = ParentsGuideSeverity.SEVERE,
|
||||
scenes = listOf(ParentsGuideScene("A long preview description used to verify wrapping at large text sizes without horizontal overflow.", startSeconds = 862, spoilerLevel = ParentsGuideSpoilerLevel.MAJOR)),
|
||||
),
|
||||
),
|
||||
sources = listOf(ParentsGuideSource("moderator_entry", "Preview source", attributionText = "Preview only")),
|
||||
),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun PreviewFrame(content: @Composable () -> Unit) {
|
||||
MaterialTheme { Box(Modifier.width(390.dp).padding(16.dp)) { content() } }
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ParentsGuideCollapsedPreview() = PreviewFrame {
|
||||
ParentsGuideSection(ParentsGuideUiState.Available(previewData), onRetry = {}, onContribute = {})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ParentsGuideExpandedPreview() = PreviewFrame {
|
||||
ParentsGuideSection(ParentsGuideUiState.Available(previewData), initiallyExpanded = true, onRetry = {}, onContribute = {})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ParentsGuideSevereCategoryPreview() = PreviewFrame {
|
||||
ParentsGuideCategoryRow(previewData.guide.categories[1], expanded = true, showSpoilers = true, showTimestamps = true, onToggle = {})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ParentsGuideUnavailablePreview() = PreviewFrame {
|
||||
ParentsGuideSection(ParentsGuideUiState.Unavailable(), initiallyExpanded = true, onRetry = {}, onContribute = {})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ParentsGuideLoadingPreview() = PreviewFrame {
|
||||
ParentsGuideSection(ParentsGuideUiState.Loading, initiallyExpanded = true, onRetry = {}, onContribute = {})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ParentsGuideErrorPreview() = PreviewFrame {
|
||||
ParentsGuideSection(ParentsGuideUiState.Error(), initiallyExpanded = true, onRetry = {}, onContribute = {})
|
||||
}
|
||||
|
||||
@Preview(fontScale = 1.8f)
|
||||
@Composable
|
||||
private fun ParentsGuideLargeTextPreview() = PreviewFrame {
|
||||
ParentsGuideCategoryRow(previewData.guide.categories[1], expanded = true, showSpoilers = true, showTimestamps = true, onToggle = {})
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ParentsGuideRtlPreview() {
|
||||
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) {
|
||||
PreviewFrame { ParentsGuideSection(ParentsGuideUiState.Available(previewData), initiallyExpanded = true, onRetry = {}, onContribute = {}) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
package com.nuvio.app.features.parentsguide
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.library.LibraryClock
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val AvailableTtlMs = 24 * 60 * 60 * 1_000L
|
||||
private const val UnavailableTtlMs = 60 * 60 * 1_000L
|
||||
|
||||
internal class ParentsGuideCache(
|
||||
private val now: () -> Long,
|
||||
) {
|
||||
private data class Entry(val data: ParentsGuideData, val storedAt: Long)
|
||||
private val values = mutableMapOf<String, Entry>()
|
||||
|
||||
fun get(key: String, allowStale: Boolean = false): ParentsGuideData? {
|
||||
val entry = values[key] ?: return null
|
||||
val ttl = if (entry.data.guide.overallStatus == ParentsGuideStatus.UNAVAILABLE) UnavailableTtlMs else AvailableTtlMs
|
||||
return entry.data.takeIf { allowStale || now() - entry.storedAt <= ttl }
|
||||
}
|
||||
|
||||
fun put(key: String, data: ParentsGuideData) {
|
||||
values[key] = Entry(data, now())
|
||||
}
|
||||
|
||||
fun clear(key: String) {
|
||||
values.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ParentsGuideRemoteDataSource(
|
||||
private val apiBaseUrl: String,
|
||||
private val fetch: suspend (String) -> Pair<Int, String> = { url ->
|
||||
httpRequestRaw("GET", url, mapOf("Accept" to "application/json"), "").let { it.status to it.body }
|
||||
},
|
||||
) {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun fetch(request: ParentsGuideRequest): ParentsGuideData {
|
||||
require(apiBaseUrl.isNotBlank()) { "Parents Guide API is not configured" }
|
||||
val query = buildList {
|
||||
request.imdbId?.let { add("imdbId=$it") }
|
||||
request.tmdbId?.let { add("tmdbId=$it") }
|
||||
request.stremioId?.let { add("stremioId=${encodeQueryValue(it)}") }
|
||||
add("mediaType=${normalizeMediaType(request.mediaType)}")
|
||||
request.season?.let { add("season=$it") }
|
||||
request.episode?.let { add("episode=$it") }
|
||||
add("language=en")
|
||||
}.joinToString("&")
|
||||
val (status, body) = fetch("${apiBaseUrl.trimEnd('/')}/api/v1/guide?$query")
|
||||
check(status in 200..299) { "Parents Guide request failed ($status)" }
|
||||
return json.decodeFromString<ParentsGuideEnvelope>(body).data
|
||||
}
|
||||
}
|
||||
|
||||
internal class ParentsGuideClient(
|
||||
private val remote: ParentsGuideRemoteDataSource,
|
||||
private val cache: ParentsGuideCache,
|
||||
) {
|
||||
private val locks = mutableMapOf<String, Mutex>()
|
||||
|
||||
suspend fun load(request: ParentsGuideRequest, forceRefresh: Boolean = false): ParentsGuideUiState {
|
||||
val key = request.cacheKey()
|
||||
if (!forceRefresh) cache.get(key)?.let { return it.toUiState(fromCache = true) }
|
||||
val lock = locks.getOrPut(key) { Mutex() }
|
||||
return lock.withLock {
|
||||
if (!forceRefresh) cache.get(key)?.let { return@withLock it.toUiState(fromCache = true) }
|
||||
runCatching { remote.fetch(request) }
|
||||
.fold(
|
||||
onSuccess = { data ->
|
||||
cache.put(key, data)
|
||||
data.toUiState(fromCache = false)
|
||||
},
|
||||
onFailure = {
|
||||
cache.get(key, allowStale = true)?.toUiState(fromCache = true)
|
||||
?: ParentsGuideUiState.Error()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal object ParentsGuideRepository {
|
||||
private val log = Logger.withTag("ParentsGuideDetails")
|
||||
private var configuredUrl: String? = null
|
||||
private var client: ParentsGuideClient? = null
|
||||
|
||||
suspend fun load(apiBaseUrl: String, request: ParentsGuideRequest, forceRefresh: Boolean = false): ParentsGuideUiState {
|
||||
if (apiBaseUrl.isBlank()) return ParentsGuideUiState.Unavailable()
|
||||
if (client == null || configuredUrl != apiBaseUrl) {
|
||||
configuredUrl = apiBaseUrl
|
||||
client = ParentsGuideClient(
|
||||
remote = ParentsGuideRemoteDataSource(apiBaseUrl),
|
||||
cache = ParentsGuideCache(LibraryClock::nowEpochMs),
|
||||
)
|
||||
}
|
||||
return runCatching {
|
||||
val primary = client!!.load(request, forceRefresh)
|
||||
if (request.episode != null && primary is ParentsGuideUiState.Unavailable) {
|
||||
when (val fallback = client!!.load(request.copy(mediaType = "series", season = null, episode = null), forceRefresh)) {
|
||||
is ParentsGuideUiState.Available -> fallback.copy(isSeriesFallback = true)
|
||||
else -> primary
|
||||
}
|
||||
} else {
|
||||
primary
|
||||
}
|
||||
}
|
||||
.onFailure { log.w(it) { "Unable to load Parents Guide" } }
|
||||
.getOrElse { ParentsGuideUiState.Error() }
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveParentsGuideRequest(type: String, id: String): ParentsGuideRequest? {
|
||||
val imdb = Regex("tt\\d{5,12}").find(id)?.value
|
||||
val tmdb = id.split(':', '/', '|').let { parts ->
|
||||
parts.getOrNull(parts.indexOfFirst { it.equals("tmdb", ignoreCase = true) } + 1)?.toIntOrNull()
|
||||
?: id.takeIf { it.all(Char::isDigit) }?.toIntOrNull()
|
||||
}
|
||||
if (imdb == null && tmdb == null && id.isBlank()) return null
|
||||
val numbers = id.split(':')
|
||||
val season = numbers.takeLast(2).getOrNull(0)?.toIntOrNull()
|
||||
val episode = numbers.lastOrNull()?.toIntOrNull().takeIf { numbers.size >= 3 }
|
||||
return ParentsGuideRequest(
|
||||
mediaType = normalizeMediaType(type),
|
||||
imdbId = imdb,
|
||||
tmdbId = tmdb,
|
||||
stremioId = id.takeIf { it.isNotBlank() },
|
||||
season = season,
|
||||
episode = episode,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ParentsGuideRequest.cacheKey(): String = listOf(mediaType, imdbId, tmdbId, stremioId, season, episode).joinToString("|")
|
||||
|
||||
private fun ParentsGuideData.toUiState(fromCache: Boolean): ParentsGuideUiState =
|
||||
if (guide.overallStatus == ParentsGuideStatus.UNAVAILABLE) ParentsGuideUiState.Unavailable(fromCache)
|
||||
else ParentsGuideUiState.Available(copy(guide = guide.copy(categories = orderedCategories(guide.categories))), fromCache)
|
||||
|
||||
private fun normalizeMediaType(type: String): String = when (type.lowercase()) {
|
||||
"series", "show", "tv" -> "series"
|
||||
"episode" -> "episode"
|
||||
else -> "movie"
|
||||
}
|
||||
|
||||
private fun encodeQueryValue(value: String): String = buildString {
|
||||
value.forEach { char ->
|
||||
when {
|
||||
char.isLetterOrDigit() || char in "-._~" -> append(char)
|
||||
else -> append('%').append(char.code.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
package com.nuvio.app.features.parentsguide
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.background
|
||||
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.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowUp
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
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.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun ParentsGuideSection(
|
||||
state: ParentsGuideUiState,
|
||||
showTimestamps: Boolean = true,
|
||||
hideSpoilersByDefault: Boolean = true,
|
||||
initiallyExpanded: Boolean = false,
|
||||
onRetry: () -> Unit,
|
||||
onContribute: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(initiallyExpanded) }
|
||||
var showSpoilers by remember { mutableStateOf(!hideSpoilersByDefault) }
|
||||
val expandedCategories = remember { mutableStateMapOf<ParentsGuideCategoryType, Boolean>() }
|
||||
val title = stringResource(Res.string.parents_guide_title)
|
||||
|
||||
Column(modifier = modifier.fillMaxWidth().animateContentSize()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = 48.dp)
|
||||
.clickable(role = Role.Button) { expanded = !expanded }
|
||||
.semantics { heading(); contentDescription = title }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = stringResource(if (expanded) Res.string.parents_guide_collapse else Res.string.parents_guide_expand),
|
||||
)
|
||||
}
|
||||
|
||||
when (state) {
|
||||
is ParentsGuideUiState.Available -> state.data.guide.overallSummary?.takeIf(String::isNotBlank)?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
is ParentsGuideUiState.Unavailable -> if (!expanded) {
|
||||
Text(stringResource(Res.string.parents_guide_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
AnimatedVisibility(expanded) {
|
||||
when (state) {
|
||||
ParentsGuideUiState.Loading -> LoadingGuide()
|
||||
is ParentsGuideUiState.Available -> Column {
|
||||
if (state.data.guide.overallStatus == ParentsGuideStatus.PARTIAL) {
|
||||
Text(
|
||||
stringResource(Res.string.parents_guide_partial),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
if (state.isSeriesFallback) {
|
||||
Text(stringResource(Res.string.parents_guide_series_fallback), style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
|
||||
TextButton(onClick = { showSpoilers = !showSpoilers }) {
|
||||
Text(stringResource(if (showSpoilers) Res.string.parents_guide_hide_spoilers else Res.string.parents_guide_show_spoilers))
|
||||
}
|
||||
}
|
||||
state.data.guide.categories.forEach { category ->
|
||||
ParentsGuideCategoryRow(
|
||||
category = category,
|
||||
expanded = expandedCategories[category.category] == true,
|
||||
showSpoilers = showSpoilers,
|
||||
showTimestamps = showTimestamps,
|
||||
onToggle = { expandedCategories[category.category] = expandedCategories[category.category] != true },
|
||||
)
|
||||
}
|
||||
state.data.guide.sources.forEach { source ->
|
||||
Text(
|
||||
text = "${stringResource(Res.string.parents_guide_source)}: ${source.sourceName}${source.attributionText?.let { " — $it" }.orEmpty()}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
if (state.data.guide.categories.any { category -> category.scenes.any { it.startSeconds != null } }) {
|
||||
Text(stringResource(Res.string.parents_guide_timestamp_disclaimer), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
}
|
||||
is ParentsGuideUiState.Unavailable -> UnavailableGuide(onContribute)
|
||||
is ParentsGuideUiState.Error -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
|
||||
Text(stringResource(Res.string.parents_guide_error), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
TextButton(onClick = onRetry) { Text(stringResource(Res.string.parents_guide_retry)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ParentsGuideCategoryRow(
|
||||
category: ParentsGuideCategory,
|
||||
expanded: Boolean,
|
||||
showSpoilers: Boolean,
|
||||
showTimestamps: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().sizeIn(minHeight = 48.dp).clickable(role = Role.Button, onClick = onToggle).padding(vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(stringResource(category.category.labelResource()), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f))
|
||||
SeverityBadge(category.severity)
|
||||
Icon(if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, contentDescription = stringResource(if (expanded) Res.string.parents_guide_hide_scenes else Res.string.parents_guide_show_scenes))
|
||||
}
|
||||
AnimatedVisibility(expanded) {
|
||||
Column(modifier = Modifier.padding(start = 12.dp, bottom = 8.dp)) {
|
||||
category.summary?.takeIf(String::isNotBlank)?.let { Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 6.dp)) }
|
||||
visibleScenes(category.scenes, showSpoilers).forEach { scene ->
|
||||
ParentsGuideSceneItem(scene, showTimestamps)
|
||||
}
|
||||
if (!showSpoilers && category.scenes.any { it.spoilerLevel != ParentsGuideSpoilerLevel.NONE }) {
|
||||
Text(stringResource(Res.string.parents_guide_spoiler_hidden), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ParentsGuideSceneItem(scene: ParentsGuideScene, showTimestamp: Boolean) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp)) {
|
||||
if (showTimestamp && scene.startSeconds != null) {
|
||||
Text(formatGuideTimestamp(scene.startSeconds), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
Text("• ${scene.description}", style = MaterialTheme.typography.bodySmall)
|
||||
if (scene.verificationStatus == "moderator_verified") {
|
||||
Text(stringResource(Res.string.parents_guide_verified), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SeverityBadge(severity: ParentsGuideSeverity) {
|
||||
val text = stringResource(severity.labelResource())
|
||||
val container = when (severity) {
|
||||
ParentsGuideSeverity.SEVERE -> MaterialTheme.colorScheme.errorContainer
|
||||
ParentsGuideSeverity.MODERATE -> MaterialTheme.colorScheme.tertiaryContainer
|
||||
ParentsGuideSeverity.MILD -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
}
|
||||
Box(modifier = Modifier.padding(end = 6.dp).background(container, RoundedCornerShape(6.dp)).padding(horizontal = 8.dp, vertical = 3.dp)) {
|
||||
Text(text, style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LoadingGuide() {
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
CircularProgressIndicator(modifier = Modifier.sizeIn(maxWidth = 20.dp, maxHeight = 20.dp))
|
||||
Text(stringResource(Res.string.parents_guide_loading), style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UnavailableGuide(onContribute: () -> Unit) {
|
||||
Column(modifier = Modifier.padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(stringResource(Res.string.parents_guide_unavailable), style = MaterialTheme.typography.bodySmall)
|
||||
Button(onClick = onContribute) { Text(stringResource(Res.string.parents_guide_contribute)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ParentsGuideCategoryType.labelResource(): StringResource = when (this) {
|
||||
ParentsGuideCategoryType.SEX_NUDITY -> Res.string.parents_guide_sex_nudity
|
||||
ParentsGuideCategoryType.VIOLENCE_GORE -> Res.string.parents_guide_violence_gore
|
||||
ParentsGuideCategoryType.PROFANITY -> Res.string.parents_guide_profanity
|
||||
ParentsGuideCategoryType.ALCOHOL_DRUGS_SMOKING -> Res.string.parents_guide_alcohol_drugs_smoking
|
||||
ParentsGuideCategoryType.FRIGHTENING_INTENSE -> Res.string.parents_guide_frightening_intense
|
||||
}
|
||||
|
||||
private fun ParentsGuideSeverity.labelResource(): StringResource = when (this) {
|
||||
ParentsGuideSeverity.NONE -> Res.string.parents_guide_severity_none
|
||||
ParentsGuideSeverity.MILD -> Res.string.parents_guide_severity_mild
|
||||
ParentsGuideSeverity.MODERATE -> Res.string.parents_guide_severity_moderate
|
||||
ParentsGuideSeverity.SEVERE -> Res.string.parents_guide_severity_severe
|
||||
ParentsGuideSeverity.UNKNOWN -> Res.string.parents_guide_severity_unknown
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.nuvio.app.features.parentsguide
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class ParentsGuideRepositoryTest {
|
||||
private val response = """
|
||||
{"success":true,"data":{"identity":{"mediaType":"movie","title":"Test fixture","imdbId":"tt1234567"},"guide":{"id":"guide-1","overallStatus":"partial","contentVersion":1,"categories":[{"category":"profanity","severity":"severe","scenes":[{"description":"Test scene","startSeconds":862,"spoilerLevel":"major","verificationStatus":"moderator_verified"}]},{"category":"sex_nudity","severity":"none","scenes":[]}],"sources":[{"sourceType":"moderator_entry","sourceName":"Test fixture"}]}}}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `json serialization preserves status timestamps and provenance`() {
|
||||
val parsed = Json { ignoreUnknownKeys = true }.decodeFromString<ParentsGuideEnvelope>(response)
|
||||
assertEquals(ParentsGuideStatus.PARTIAL, parsed.data.guide.overallStatus)
|
||||
assertEquals(862, parsed.data.guide.categories[0].scenes[0].startSeconds)
|
||||
assertEquals("Test fixture", parsed.data.guide.sources[0].sourceName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `repository returns partial success and then a cache hit`() = runBlocking {
|
||||
var calls = 0
|
||||
val client = ParentsGuideClient(
|
||||
ParentsGuideRemoteDataSource("https://example.test") { calls += 1; 200 to response },
|
||||
ParentsGuideCache { 1_000L },
|
||||
)
|
||||
val request = ParentsGuideRequest("movie", imdbId = "tt1234567")
|
||||
val first = assertIs<ParentsGuideUiState.Available>(client.load(request))
|
||||
assertEquals(ParentsGuideStatus.PARTIAL, first.data.guide.overallStatus)
|
||||
assertEquals(false, first.fromCache)
|
||||
assertEquals(true, assertIs<ParentsGuideUiState.Available>(client.load(request)).fromCache)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache expires and refreshes`() = runBlocking {
|
||||
var now = 0L
|
||||
var calls = 0
|
||||
val client = ParentsGuideClient(
|
||||
ParentsGuideRemoteDataSource("https://example.test") { calls += 1; 200 to response },
|
||||
ParentsGuideCache { now },
|
||||
)
|
||||
val request = ParentsGuideRequest("movie", imdbId = "tt1234567")
|
||||
client.load(request)
|
||||
now = 24 * 60 * 60 * 1_000L + 1
|
||||
client.load(request)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network error is isolated from details screen`() = runBlocking {
|
||||
val client = ParentsGuideClient(
|
||||
ParentsGuideRemoteDataSource("https://example.test") { 503 to "" },
|
||||
ParentsGuideCache { 0L },
|
||||
)
|
||||
assertIs<ParentsGuideUiState.Error>(client.load(ParentsGuideRequest("movie", imdbId = "tt1234567")))
|
||||
Unit
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `identifier resolution supports imdb tmdb and episode shapes`() {
|
||||
val imdb = resolveParentsGuideRequest("series", "tt1234567:2:4")!!
|
||||
assertEquals("tt1234567", imdb.imdbId)
|
||||
assertEquals(2, imdb.season)
|
||||
assertEquals(4, imdb.episode)
|
||||
assertEquals(42, resolveParentsGuideRequest("movie", "tmdb:42")?.tmdbId)
|
||||
assertNull(resolveParentsGuideRequest("movie", ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `categories order and severity remain canonical`() {
|
||||
val parsed = Json { ignoreUnknownKeys = true }.decodeFromString<ParentsGuideEnvelope>(response)
|
||||
val ordered = orderedCategories(parsed.data.guide.categories)
|
||||
assertEquals(listOf(ParentsGuideCategoryType.SEX_NUDITY, ParentsGuideCategoryType.PROFANITY), ordered.map { it.category })
|
||||
assertEquals(ParentsGuideSeverity.SEVERE, parsed.data.guide.categories.first().severity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `spoiler hiding only keeps non spoiler scenes`() {
|
||||
val scenes = listOf(
|
||||
ParentsGuideScene("Safe", spoilerLevel = ParentsGuideSpoilerLevel.NONE),
|
||||
ParentsGuideScene("Hidden", spoilerLevel = ParentsGuideSpoilerLevel.MAJOR),
|
||||
)
|
||||
assertEquals(listOf("Safe"), visibleScenes(scenes, showSpoilers = false).map { it.description })
|
||||
assertEquals(2, visibleScenes(scenes, showSpoilers = true).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `timestamp formatting supports long runtimes`() {
|
||||
assertEquals("14:22", formatGuideTimestamp(862))
|
||||
assertEquals("01:14:22", formatGuideTimestamp(4462))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue