diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c714368ee4a..d833a2a4d94 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -292,6 +292,7 @@ dependencies { implementation(libs.bundles.compose) implementation(libs.activity.compose) + implementation(libs.tv.material) implementation(libs.kotlinx.io.core) // Logcat parser implementation(project(":library")) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ee4c978f2be..ede2076b904 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -271,6 +271,18 @@ android:foregroundServiceType="dataSync" tools:node="merge" /> + + + + diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt new file mode 100644 index 00000000000..6d5df180ecb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -0,0 +1,70 @@ +package com.lagradost.cloudstream3.tv + +import android.os.Bundle +import android.view.KeyEvent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.lifecycleScope +import com.lagradost.cloudstream3.CommonActivity +import com.lagradost.cloudstream3.CommonActivity.loadThemes +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest +import com.lagradost.cloudstream3.tv.navigation.TvNavigationShell +import com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge +import kotlinx.coroutines.launch + +/** + * Compose-for-TV host activity (Phase 10: CW polish / Hero Watch Now → existing GeneratorPlayer). + * + * Layout: [R.layout.activity_tv_compose_probe] — + * Compose shell + [R.id.tv_player_container] Fragment boundary for GeneratorPlayer. + * + * Not registered as MAIN / LEANBACK_LAUNCHER — default phone + legacy TV startup unchanged. + * + * Launch (debug / stableDebug package id suffix `.debug`): + * ``` + * adb shell am start -n com.lagradost.cloudstream3.debug/com.lagradost.cloudstream3.tv.TvComposeProbeActivity + * ``` + * + * Also available from Settings → Updates → Actions → "Compose TV (debug)" when BuildConfig.DEBUG. + */ +class TvComposeProbeActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + loadThemes(this) + enableEdgeToEdge() + super.onCreate(savedInstanceState) + CommonActivity.init(this) + setContentView(R.layout.activity_tv_compose_probe) + findViewById(R.id.tv_compose_host).setContent { + TvTheme { + TvNavigationShell( + onPlaybackRequest = ::onPlaybackRequest, + ) + } + } + } + + override fun onResume() { + super.onResume() + CommonActivity.setActivityInstance(this) + } + + override fun dispatchKeyEvent(event: KeyEvent): Boolean = + CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) + + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = + CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) + + /** + * Activity-level callback from Home CW Resume / Details Watch Now / Play Episode. + * Request stays immutable; mock rejected by bridge. No Compose seek / PosDur writes here. + * After GeneratorPlayer pops, Home re-reads CW (read-only) and restores focus. + */ + private fun onPlaybackRequest(request: TvPlaybackRequest) { + lifecycleScope.launch { + val result = TvPlaybackBridge.launch(this@TvComposeProbeActivity, request) + TvPlaybackBridge.report(this@TvComposeProbeActivity, result) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvProbeScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvProbeScreen.kt new file mode 100644 index 00000000000..0751dfe21ea --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvProbeScreen.kt @@ -0,0 +1,154 @@ +package com.lagradost.cloudstream3.tv + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.ClassicCard +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.SurfaceDefaults +import androidx.tv.material3.Text +import androidx.tv.material3.WideButton +import androidx.tv.material3.WideButtonDefaults + +/** + * Minimal Compose-for-TV focus probe: title + small row of focusable cards/buttons. + * Obvious focus scale, D-pad friendly, wide spacing. No carousel / lazy rails / catalog. + */ +@Composable +fun TvProbeScreen() { + Surface( + modifier = Modifier.fillMaxSize(), + colors = SurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onBackground, + ), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 48.dp, vertical = 36.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), + ) { + Text( + text = "CloudStream TV Compose Probe", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Phase 1 — D-pad focus / scale smoke test (no catalog, no player)", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Focusable cards", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.Top, + ) { + ProbeClassicCard(title = "Card A", accent = MaterialTheme.colorScheme.primary) + ProbeClassicCard(title = "Card B", accent = MaterialTheme.colorScheme.secondary) + ProbeClassicCard(title = "Card C", accent = MaterialTheme.colorScheme.tertiary) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Focusable buttons", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = {}, + scale = ButtonDefaults.scale(focusedScale = 1.12f), + ) { + Text("Continue") + } + Button( + onClick = {}, + scale = ButtonDefaults.scale(focusedScale = 1.12f), + ) { + Text("Details") + } + } + + WideButton( + onClick = {}, + modifier = Modifier.fillMaxWidth(0.55f), + scale = WideButtonDefaults.scale(focusedScale = 1.05f), + ) { + Text("Wide action (focus me with D-pad)") + } + } + } +} + +@Composable +private fun ProbeClassicCard( + title: String, + accent: Color, +) { + ClassicCard( + onClick = {}, + modifier = Modifier.width(180.dp), + image = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(100.dp) + .background(accent), + contentAlignment = Alignment.Center, + ) { + Text( + text = title.takeLast(1), + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + }, + title = { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.padding(top = 8.dp), + ) + }, + subtitle = { + Text( + text = "Focus scales up", + style = MaterialTheme.typography.bodySmall, + ) + }, + scale = CardDefaults.scale(focusedScale = 1.12f), + contentPadding = PaddingValues(12.dp), + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt new file mode 100644 index 00000000000..c6463830eae --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt @@ -0,0 +1,42 @@ +package com.lagradost.cloudstream3.tv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.darkColorScheme + +/** + * Cinematic dark high-contrast theme for Compose TV. + * Uses only [androidx.tv.material3] — never phone Material3. + */ +private val TvCinematicDarkColorScheme = darkColorScheme( + primary = Color(0xFFFFB74D), + onPrimary = Color(0xFF1A1200), + primaryContainer = Color(0xFF5C3B00), + onPrimaryContainer = Color(0xFFFFE0B2), + secondary = Color(0xFF80CBC4), + onSecondary = Color(0xFF00201D), + secondaryContainer = Color(0xFF004D47), + onSecondaryContainer = Color(0xFFB2DFDB), + tertiary = Color(0xFFCE93D8), + onTertiary = Color(0xFF2A0030), + background = Color(0xFF07070A), + onBackground = Color(0xFFF5F5F7), + surface = Color(0xFF121218), + onSurface = Color(0xFFF5F5F7), + surfaceVariant = Color(0xFF24242E), + onSurfaceVariant = Color(0xFFC8C8D2), + border = Color(0xFF5A5A68), + borderVariant = Color(0xFF3A3A44), + error = Color(0xFFFF8A80), + onError = Color(0xFF3B0000), + scrim = Color(0xCC000000), +) + +@Composable +fun TvTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = TvCinematicDarkColorScheme, + content = content, + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvConfirmDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvConfirmDialog.kt new file mode 100644 index 00000000000..983de3e76b0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvConfirmDialog.kt @@ -0,0 +1,95 @@ +package com.lagradost.cloudstream3.tv.components + +import androidx.activity.compose.BackHandler +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.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.SurfaceDefaults +import androidx.tv.material3.Text + +/** + * Minimal TV confirm dialog — not a dialog framework. + * Back cancels; initial focus on Cancel (never silent destructive). + */ +@Composable +fun TvConfirmDialog( + title: String, + message: String, + confirmLabel: String, + onConfirm: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val cancelFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + runCatching { cancelFocus.requestFocus() } + } + BackHandler(onBack = onDismiss) + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier + .widthIn(min = 360.dp, max = 560.dp) + .padding(24.dp), + shape = RoundedCornerShape(16.dp), + colors = SurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(28.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + onClick = onDismiss, + modifier = Modifier.focusRequester(cancelFocus), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text("Cancel") + } + Button( + onClick = onConfirm, + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(confirmLabel) + } + } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvEnumDialog.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvEnumDialog.kt new file mode 100644 index 00000000000..80fc880240e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvEnumDialog.kt @@ -0,0 +1,126 @@ +package com.lagradost.cloudstream3.tv.components + +import androidx.activity.compose.BackHandler +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.SurfaceDefaults +import androidx.tv.material3.Text +import com.lagradost.cloudstream3.tv.model.TvSettingOption + +/** + * TV list-choice dialog — Back cancels; CENTER on a row confirms that option. + * Initial focus on currently selected option when possible. + */ +@Composable +fun TvEnumDialog( + title: String, + options: List, + selectedKey: String?, + onSelect: (TvSettingOption) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + val initialIndex = options.indexOfFirst { it.key == selectedKey }.coerceAtLeast(0) + val focusRequesters = remember(options.size) { + List(options.size) { FocusRequester() } + } + LaunchedEffect(options, selectedKey) { + focusRequesters.getOrNull(initialIndex)?.let { runCatching { it.requestFocus() } } + } + BackHandler(onBack = onDismiss) + + Box( + modifier = modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.72f)), + contentAlignment = Alignment.Center, + ) { + Surface( + modifier = Modifier + .widthIn(min = 360.dp, max = 560.dp) + .heightIn(max = 520.dp) + .padding(24.dp), + shape = RoundedCornerShape(16.dp), + colors = SurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Back cancels · Select confirms", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + itemsIndexed(options, key = { _, o -> o.key }) { index, option -> + val selected = option.key == selectedKey + Surface( + onClick = { onSelect(option) }, + modifier = Modifier + .fillMaxWidth() + .then( + focusRequesters.getOrNull(index)?.let { + Modifier.focusRequester(it) + } ?: Modifier, + ), + scale = ClickableSurfaceDefaults.scale( + focusedScale = TvFocusScale.ButtonFocused, + ), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + }, + focusedContainerColor = MaterialTheme.colorScheme.primary.copy( + alpha = 0.35f, + ), + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = if (selected) "● ${option.label}" else "○ ${option.label}", + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvFocusScale.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvFocusScale.kt new file mode 100644 index 00000000000..6e45c07c4df --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvFocusScale.kt @@ -0,0 +1,49 @@ +package com.lagradost.cloudstream3.tv.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Border +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.CardGlow +import androidx.tv.material3.CardScale +import androidx.tv.material3.Glow +import androidx.tv.material3.MaterialTheme + +/** + * Reusable TV focus scale / glow tokens — obvious at 10ft, D-pad friendly. + * Uses only androidx.tv.material3 APIs available in tv-material 1.1.0. + */ +object TvFocusScale { + const val CardFocused = 1.12f + const val CardPressed = 1.04f + const val ButtonFocused = 1.08f + const val HeroButtonFocused = 1.06f + + fun cardScale( + focused: Float = CardFocused, + pressed: Float = CardPressed, + ): CardScale = CardDefaults.scale( + focusedScale = focused, + pressedScale = pressed, + ) + + @Composable + fun cardGlow( + focusedColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.55f), + elevation: androidx.compose.ui.unit.Dp = 14.dp, + ): CardGlow = CardDefaults.glow( + focusedGlow = Glow(elevationColor = focusedColor, elevation = elevation), + pressedGlow = Glow(elevationColor = focusedColor.copy(alpha = 0.35f), elevation = 8.dp), + ) + + @Composable + fun cardBorder( + focusedColor: Color = MaterialTheme.colorScheme.primary, + ) = CardDefaults.border( + focusedBorder = Border( + border = androidx.compose.foundation.BorderStroke(3.dp, focusedColor), + shape = MaterialTheme.shapes.medium, + ), + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvLifecycleResume.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvLifecycleResume.kt new file mode 100644 index 00000000000..edac488a3b3 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvLifecycleResume.kt @@ -0,0 +1,28 @@ +package com.lagradost.cloudstream3.tv.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner + +/** + * Invokes [onResume] on Activity/Fragment [Lifecycle.Event.ON_RESUME]. + * Used for enter/resume reloads without polling or recomposition DataStore spam. + */ +@Composable +fun TvOnResume(onResume: () -> Unit) { + val context = LocalContext.current + val owner = context as? LifecycleOwner ?: return + val latestOnResume by rememberUpdatedState(onResume) + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) latestOnResume() + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt new file mode 100644 index 00000000000..a50ead72449 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt @@ -0,0 +1,171 @@ +package com.lagradost.cloudstream3.tv.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Card +import androidx.tv.material3.CardDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.tv.model.TvAvailabilityKind +import com.lagradost.cloudstream3.tv.model.TvMediaItem + +private val PosterPlaceholder = Color(0xFF2A2A2E) +private const val PosterWidthPx = 400 +private const val PosterHeightPx = 600 + +@Composable +fun TvMediaCard( + item: TvMediaItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onFocused: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + val context = LocalContext.current + val placeholder = ColorPainter(PosterPlaceholder) + val request = ImageRequest.Builder(context) + .data(item.posterUrl?.takeIf { it.isNotBlank() }) + .size(PosterWidthPx, PosterHeightPx) + .crossfade(true) + .httpHeaders( + NetworkHeaders.Builder().also { headers -> + headers["User-Agent"] = USER_AGENT + item.posterHeaders?.forEach { (k, v) -> headers[k] = v } + }.build(), + ) + .build() + + val availability = item.availabilityKind + val stale = availability != null && availability != TvAvailabilityKind.Available + + Column( + modifier = modifier.width(148.dp), + ) { + Card( + onClick = onClick, + onLongClick = onLongClick, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(2f / 3f) + .onFocusChanged { state -> + if (state.isFocused) onFocused?.invoke() + }, + scale = TvFocusScale.cardScale(), + glow = TvFocusScale.cardGlow(), + border = TvFocusScale.cardBorder(), + shape = CardDefaults.shape(shape = RoundedCornerShape(12.dp)), + ) { + Box(Modifier.fillMaxSize()) { + AsyncImage( + model = request, + contentDescription = item.title, + contentScale = ContentScale.Crop, + placeholder = placeholder, + error = placeholder, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .height(56.dp) + .background( + Brush.verticalGradient( + listOf(Color.Transparent, Color.Black.copy(alpha = 0.75f)), + ), + ), + ) + // Progress only for valid resume — never fake progress on stale cards. + if (!stale) { + item.progressFraction?.let { progress -> + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(4.dp) + .background(Color.White.copy(alpha = 0.25f)), + ) { + Box( + modifier = Modifier + .fillMaxWidth(progress.coerceIn(0f, 1f)) + .height(4.dp) + .background(MaterialTheme.colorScheme.primary), + ) + } + } + } + if (stale) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.45f)), + ) + Text( + text = (availability ?: TvAvailabilityKind.Unavailable).label, + style = MaterialTheme.typography.labelMedium, + color = Color.White, + modifier = Modifier + .align(Alignment.Center) + .background( + Color.Black.copy(alpha = 0.65f), + RoundedCornerShape(6.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } + Spacer(Modifier.height(10.dp)) + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitleText = when { + item.isMock -> if (item.subtitle.isNotBlank()) "${item.subtitle} · Demo" else "Demo — unavailable" + else -> item.subtitle + } + if (subtitleText.isNotBlank()) { + Text( + text = subtitleText, + style = MaterialTheme.typography.bodySmall, + color = if (stale) { + MaterialTheme.colorScheme.error + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp), + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt new file mode 100644 index 00000000000..2900a0002ae --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey +import com.lagradost.cloudstream3.tv.model.TvContentRail +import com.lagradost.cloudstream3.tv.model.TvContinueWatchingItem +import com.lagradost.cloudstream3.tv.model.TvRailIds +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE +import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds +import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched +import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos +import com.lagradost.cloudstream3.utils.DataStoreHelper.removeLastWatched +import com.lagradost.cloudstream3.utils.downloader.DownloadObjects + +/** + * Continue Watching adapter (Phase 8/10). + * + * Reads are pure; Phase 10 remove uses existing [removeLastWatched] only + * (no new DataStore keys / hide flags). + * + * Why not [com.lagradost.cloudstream3.ui.home.HomeViewModel.getResumeWatching]: + * that companion can **write** `DOWNLOAD_HEADER_CACHE` when restoring from + * `DOWNLOAD_HEADER_CACHE_BACKUP` (Phase 3/8 forbid persistence writes for TV). + * + * Read-only source APIs (exact): + * - [DataStoreHelper.getAllResumeStateIds] + * - [DataStoreHelper.getLastWatched] + * - [getKey] on [DOWNLOAD_HEADER_CACHE] (and optionally BACKUP — read only, never setKey) + * - [DataStoreHelper.getViewPos] + * + * Items without a header cache entry (primary or backup) are skipped — never invent titles + * or restore keys. + */ +class TvContinueWatchingRepository { + + fun loadContinueWatching(limit: Int = 24): List { + val ids = getAllResumeStateIds() ?: return emptyList() + return ids.mapNotNull { id -> getLastWatched(id) } + .sortedByDescending { it.updateTime } + .mapNotNull { resume -> mapResume(resume) } + .distinctBy { it.id } + .take(limit) + } + + fun loadContinueWatchingRail(limit: Int = 24): TvContentRail? { + val items = loadContinueWatching(limit) + if (items.isEmpty()) return null + return TvContentRail( + id = TvRailIds.CONTINUE, + title = "Continue Watching", + items = items.map { it.toMediaItem() }, + isMock = false, + ) + } + + + /** + * Phase 10 — remove one CW entry via existing [removeLastWatched]. + * Does not touch PosDur, bookmarks, favorites, or header cache. + * @return true when parentId was non-null and remove was invoked. + */ + fun removeContinueWatching(parentId: Int?): Boolean { + if (parentId == null) return false + removeLastWatched(parentId) + return true + } + + private fun mapResume( + resume: DownloadObjects.ResumeWatching, + ): TvContinueWatchingItem? { + val header = getKey( + DOWNLOAD_HEADER_CACHE, + resume.parentId.toString(), + ) ?: getKey( + DOWNLOAD_HEADER_CACHE_BACKUP, + resume.parentId.toString(), + ) ?: return null + + val url = header.url.takeIf { it.isNotBlank() } ?: return null + val apiName = header.apiName.takeIf { it.isNotBlank() } ?: return null + val title = header.name.takeIf { it.isNotBlank() } ?: return null + + val watchPos = getViewPos(resume.episodeId) + val progress = watchPos?.takeIf { it.duration > 0 }?.let { pos -> + (pos.position.toFloat() / pos.duration.toFloat()).coerceIn(0f, 1f) + } + + return TvContinueWatchingItem( + id = "cw-${resume.parentId}-${resume.episodeId ?: 0}", + title = title, + url = url, + apiName = apiName, + posterUrl = header.poster?.takeIf { it.isNotBlank() }, + typeLabel = header.type.name, + progressFraction = progress, + episode = resume.episode, + season = resume.season, + parentId = resume.parentId, + episodeId = resume.episodeId, + updateTimeMs = resume.updateTime, + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingResume.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingResume.kt new file mode 100644 index 00000000000..823f0484046 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingResume.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvDetailsContent +import com.lagradost.cloudstream3.tv.model.TvEpisode +import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest +import com.lagradost.cloudstream3.tv.model.TvResumeHint + +/** + * Phase 9 read-only series/anime resume resolver. + * + * Exact path when CW has season+episode (or episodeId): + * TvContentRef → [TvDetailsRepository.load] (same as Details) + * → match [TvEpisode] by episodeId, else season+episode + * → [TvPlaybackRequest.fromEpisode] → existing [com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge] + * + * Precise missing data on CW alone: Episode.data (and full episode metadata). + * Does **not** invent episode URLs, write PosDur, or add a second persistence architecture. + */ +class TvContinueWatchingResume( + private val detailsRepository: TvDetailsRepository = TvDetailsRepository(), +) { + + sealed interface ResolveResult { + data class Playback(val request: TvPlaybackRequest) : ResolveResult + /** Open shared Details with optional restore hints — episode not matched or no exact target. */ + data class OpenDetails(val ref: TvContentRef, val message: String? = null) : ResolveResult + data class Unavailable(val message: String) : ResolveResult + } + + /** + * Resolve series/anime CW click when [hint] has exact episode identity. + * Caller must only invoke for B-class series/anime with [TvResumeHint.hasExactEpisode]. + */ + suspend fun resolveSeriesResume( + ref: TvContentRef, + hint: TvResumeHint, + ): ResolveResult { + if (!hint.hasExactEpisode) { + return ResolveResult.OpenDetails(ref) + } + return when (val load = detailsRepository.load(ref)) { + is TvDetailsRepository.LoadResult.Failure -> + ResolveResult.OpenDetails(ref, load.message) + + is TvDetailsRepository.LoadResult.Success -> { + val details = load.details + if (!details.hasEpisodeSelector) { + // Loaded as Movie/Other despite series typeLabel — open Details, never swap silently. + return ResolveResult.OpenDetails( + ref, + "Content type is ${details.variantLabel} — open Details instead of forcing episode resume.", + ) + } + val episode = matchEpisode(details, hint) + when { + episode == null -> ResolveResult.OpenDetails( + ref.copy( + // Preserve title; restore still applied by Details from hint. + ), + "Saved episode not found — pick one on Details.", + ) + !episode.isPlayable -> ResolveResult.OpenDetails( + ref, + "Saved episode has no playable data — pick another on Details.", + ) + else -> ResolveResult.Playback( + TvPlaybackRequest.fromEpisode(details, episode, isMock = false), + ) + } + } + } + } + + /** + * Match order (never invent): + * 1. episodeId exact (aligns with ResultViewModel2 / player cache keys) + * 2. seasonIndex + episodeNumber (display season ?: seasonIndex) + * Prefer playable matches when multiple. + */ + fun matchEpisode(details: TvDetailsContent, hint: TvResumeHint): TvEpisode? { + val byId = hint.episodeId?.let { id -> details.episodeById(id) } + if (byId != null) return byId + + val targetEp = hint.episode ?: return null + val targetSeason = hint.season + val all = details.dubGroups.asSequence() + .flatMap { it.seasons.asSequence() } + .flatMap { season -> season.episodes.asSequence() } + .filter { ep -> + ep.episodeNumber == targetEp && + seasonMatches(ep, targetSeason) + } + .toList() + if (all.isEmpty()) return null + return all.firstOrNull { it.isPlayable } ?: all.firstOrNull() + } + + private fun seasonMatches(ep: TvEpisode, targetSeason: Int?): Boolean { + if (targetSeason == null) return true + val epSeason = ep.displaySeason ?: ep.seasonIndex ?: 0 + return epSeason == targetSeason + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsMapper.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsMapper.kt new file mode 100644 index 00000000000..6c63f1ba15a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsMapper.kt @@ -0,0 +1,260 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.DubStatus +import com.lagradost.cloudstream3.Episode +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.SeasonData +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.tv.model.TvDetailsContent +import com.lagradost.cloudstream3.tv.model.TvDubGroup +import com.lagradost.cloudstream3.tv.model.TvEpisode +import com.lagradost.cloudstream3.tv.model.TvEpisodeDefaults +import com.lagradost.cloudstream3.tv.model.TvSeason +import com.lagradost.cloudstream3.ui.result.getId + +/** + * Maps domain [LoadResponse] → immutable TV Details models. + * Separate from [TvMediaMapper] (SearchResponse / Home). Does not mutate LoadResponse. + * + * Variants handled (all concrete LoadResponse types in MainAPI.kt): + * - [MovieLoadResponse] + * - [TvSeriesLoadResponse] — episodes from List<Episode>, seasons via Episode.season + SeasonData + * - [AnimeLoadResponse] — episodes from Map<DubStatus, List<Episode>> + * - [LiveStreamLoadResponse] + * - [TorrentLoadResponse] + * - other / unknown LoadResponse implementors → variantLabel "Other" + * + * Episode id / indexing formulas mirror ResultViewModel2.postEpisodes (do not invent). + * + * Domain note: Episode.episode / Episode.season are Int? only — there are no non-int + * episode numbers in LoadResponse; null episode → (listIndex + 1), null season → bucket 0. + */ +object TvDetailsMapper { + + fun toDetails(response: LoadResponse): TvDetailsContent { + val poster = response.posterUrl?.takeIf { it.isNotBlank() } + val backdrop = response.backgroundPosterUrl?.takeIf { it.isNotBlank() } ?: poster + val rating = runCatching { + response.score?.toStringNull(minScore = 0.1, maxScore = 10) + }.getOrNull() + val runtime = response.duration?.takeIf { it > 0 }?.let { minutes -> + val h = minutes / 60 + val m = minutes % 60 + when { + h > 0 && m > 0 -> "${h}h ${m}m" + h > 0 -> "${h}h" + else -> "${m}m" + } + } + val genres = response.tags.orEmpty().map { it.trim() }.filter { it.isNotEmpty() } + val actors = response.actors.orEmpty().mapNotNull { actorData -> + actorData.actor.name.takeIf { it.isNotBlank() } + } + val showStatus = (response as? EpisodeResponse)?.showStatus?.name + val episodeCount = episodeCountOf(response) + val dubGroups = mapDubGroups(response) + val (defaultDub, defaultSeason, defaultEpisode) = TvEpisodeDefaults.defaultsFor(dubGroups) + + return TvDetailsContent( + title = response.name.ifBlank { "Untitled" }, + posterUrl = poster, + backdropUrl = backdrop, + year = response.year, + rating = rating, + runtime = runtime, + genres = genres, + synopsis = response.plot.orEmpty().trim(), + typeLabel = response.type.name, + contentRating = response.contentRating?.takeIf { it.isNotBlank() }, + showStatus = showStatus, + comingSoon = response.comingSoon, + episodeCount = episodeCount, + actors = actors, + apiName = response.apiName, + url = response.url, + variantLabel = variantLabelOf(response), + posterHeaders = response.posterHeaders?.toMap(), + dubGroups = dubGroups, + defaultDubStatusId = defaultDub, + defaultSeasonIndex = defaultSeason, + defaultEpisodeId = defaultEpisode, + ) + } + + private fun variantLabelOf(response: LoadResponse): String = when (response) { + is MovieLoadResponse -> "Movie" + is TvSeriesLoadResponse -> "TvSeries" + is AnimeLoadResponse -> "Anime" + is LiveStreamLoadResponse -> "LiveStream" + is TorrentLoadResponse -> "Torrent" + else -> "Other" + } + + private fun episodeCountOf(response: LoadResponse): Int? = when (response) { + is TvSeriesLoadResponse -> response.episodes.size.takeIf { it > 0 } + is AnimeLoadResponse -> { + val total = response.episodes.values.sumOf { it.size } + total.takeIf { it > 0 } + } + else -> null + } + + private fun mapDubGroups(response: LoadResponse): List = when (response) { + is TvSeriesLoadResponse -> { + val mainId = response.getId() + // ResultViewModel2 sorts TV episodes before assigning index / fallback episode nums. + val ordered = response.episodes + .withIndex() + .sortedBy { (it.value.season?.times(10_000) ?: 0) + (it.value.episode ?: 0) } + .map { it.value } + val seasons = groupEpisodesToSeasons( + episodes = ordered, + seasonNames = response.seasonNames, + dubStatus = DubStatus.None, + totalEpisodeIndex = { epNum, season -> + season?.let { response.getTotalEpisodeIndex(epNum, it) } + }, + idFor = { ep, episodeNumber, _ -> + mainId + (ep.season?.times(100_000) ?: 0) + episodeNumber + 1 + }, + ) + if (seasons.isEmpty()) emptyList() + else listOf( + TvDubGroup( + dubStatusId = DubStatus.None.id, + label = "", + seasons = seasons, + ), + ) + } + is AnimeLoadResponse -> { + val mainId = response.getId() + response.episodes.entries + .filter { it.value.isNotEmpty() } + .sortedBy { it.key.id } + .map { (status, eps) -> + // Anime: keep provider list order (ResultViewModel2 uses withIndex on raw list). + val seasons = groupEpisodesToSeasons( + episodes = eps, + seasonNames = response.seasonNames, + dubStatus = status, + totalEpisodeIndex = { epNum, season -> + season?.let { response.getTotalEpisodeIndex(epNum, it) } + }, + idFor = { ep, episodeNumber, _ -> + mainId + episodeNumber + status.id * 1_000_000 + + (ep.season?.times(10_000) ?: 0) + }, + ) + TvDubGroup( + dubStatusId = status.id, + label = dubLabel(status), + seasons = seasons, + ) + } + .filter { it.seasons.isNotEmpty() } + } + else -> emptyList() + } + + private fun dubLabel(status: DubStatus): String = when (status) { + DubStatus.Dubbed -> "Dub" + DubStatus.Subbed -> "Sub" + DubStatus.None -> "" + } + + /** + * Groups domain [Episode] list into [TvSeason] buckets. + * Missing Episode.season → seasonIndex 0 (same as ResultViewModel2). + * Episode.episode null → index + 1 (domain Int? only). + */ + private fun groupEpisodesToSeasons( + episodes: List, + seasonNames: List?, + dubStatus: DubStatus, + totalEpisodeIndex: (episodeNumber: Int, season: Int?) -> Int?, + idFor: (ep: Episode, episodeNumber: Int, index: Int) -> Int, + ): List { + if (episodes.isEmpty()) return emptyList() + + val existingIds = HashSet() + val bySeason = linkedMapOf>() + + for ((index, ep) in episodes.withIndex()) { + val episodeNumber = ep.episode ?: (index + 1) + val id = idFor(ep, episodeNumber, index) + if (!existingIds.add(id)) continue + + val seasonKey = ep.season ?: 0 + val seasonData = seasonNames.getSeason(ep.season) + val displaySeason = if (seasonData != null) seasonData.displaySeason else ep.season + val scoreLabel = runCatching { + ep.score?.toStringNull(minScore = 0.1, maxScore = 10) + }.getOrNull() + + val tvEp = TvEpisode( + id = id, + index = index, + episodeNumber = episodeNumber, + name = filterEpisodeName(ep.name), + description = ep.description?.takeIf { it.isNotBlank() }, + posterUrl = ep.posterUrl?.takeIf { it.isNotBlank() }, + seasonIndex = ep.season, + displaySeason = displaySeason, + data = ep.data, + airDate = ep.date, + runTime = ep.runTime, + scoreLabel = scoreLabel, + dubStatusId = dubStatus.id, + totalEpisodeIndex = totalEpisodeIndex(episodeNumber, ep.season), + isPlayable = ep.data.isNotBlank(), + ) + bySeason.getOrPut(seasonKey) { mutableListOf() }.add(tvEp) + } + + return bySeason.entries + .sortedBy { it.key } + .map { (seasonKey, eps) -> + val matched = seasonNames.getSeason(seasonKey) + TvSeason( + seasonIndex = seasonKey, + displaySeason = matched?.displaySeason ?: seasonKey.takeIf { it != 0 }, + name = matched?.name, + label = seasonLabel(matched, seasonKey), + episodes = eps.sortedWith( + compareBy({ it.episodeNumber }, { it.index }), + ), + ) + } + } + + private fun List?.getSeason(season: Int?): SeasonData? { + if (season == null) return null + return this?.firstOrNull { it.season == season } + } + + /** Mirrors ResultViewModel2.seasonToTxt without UiText / resources. */ + private fun seasonLabel(seasonData: SeasonData?, season: Int): String { + if (season == 0) return "No Season" + if (seasonData?.name != null && seasonData.displaySeason == null) { + return seasonData.name!! + } + val number = seasonData?.displaySeason ?: season + val suffix = seasonData?.name?.let { " $it" }.orEmpty() + return "Season $number$suffix" + } + + /** Mirrors ResultViewModel2.filterName — strip redundant "Episode N" titles. */ + private fun filterEpisodeName(name: String?): String? { + if (name == null) return null + Regex("^[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { + if (it.isEmpty()) return null + } + return name + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsRepository.kt new file mode 100644 index 00000000000..10fe508734b --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsRepository.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.APIHolder.getApiFromUrlNull +import com.lagradost.cloudstream3.metaproviders.SyncRedirector +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvDetailsContent +import com.lagradost.cloudstream3.ui.APIRepository + +/** + * Thin read-only bridge: content identity → APIRepository.load → immutable details. + * No Composables / Activity / navigation / focus / DataStore / player. + * + * Documented load flow (mirrors ResultViewModel2.load, without persistence side effects): + * 1. Resolve MainAPI via getApiFromNameNull(apiName) ?: getApiFromUrlNull(url) + * 2. SyncRedirector.redirect(url, api) (same as ResultViewModel2) + * 3. APIRepository(api).load(validUrl) → Resource<LoadResponse> + * - fixUrl, 10-min rolling cache, api.loadTimeoutMs, blank-tag filter + * 4. Map via [TvDetailsMapper] — never invent a load mechanism + * + * Plugin timing: if plugins are not loaded yet, getApiFromNameNull may miss the provider + * ("This provider does not exist"). APIRepository also clears its load cache on + * afterPluginsLoadedEvent. Callers should Retry after plugins settle. + * + * Intentionally omitted vs ResultViewModel2: DOWNLOAD_HEADER_CACHE writes, trailers, + * fillers, AutoResume watch-position writes, applyMeta sync — Phase 6 maps episodes read-only for TV selector. + */ +class TvDetailsRepository { + + sealed interface LoadResult { + data class Success(val details: TvDetailsContent) : LoadResult + data class Failure(val message: String) : LoadResult + } + + suspend fun load(ref: TvContentRef): LoadResult { + if (APIRepository.isInvalidData(ref.url)) { + return LoadResult.Failure("Invalid content URL") + } + + val api = getApiFromNameNull(ref.apiName) ?: getApiFromUrlNull(ref.url) + if (api == null) { + return LoadResult.Failure( + "This provider does not exist (${ref.apiName}). " + + "Retry after plugins finish loading.", + ) + } + + val validUrlResource = safeApiCall { + SyncRedirector.redirect(ref.url, api) + } + val validUrl = when (validUrlResource) { + is Resource.Success -> validUrlResource.value + is Resource.Failure -> { + return LoadResult.Failure( + validUrlResource.errorString.ifBlank { + "Failed to resolve content URL for ${ref.apiName}" + }, + ) + } + is Resource.Loading -> ref.url + } + + val repo = APIRepository(api) + return when (val data = repo.load(validUrl)) { + is Resource.Success -> LoadResult.Success(TvDetailsMapper.toDetails(data.value)) + is Resource.Failure -> LoadResult.Failure( + data.errorString.ifBlank { + "Failed to load details from ${repo.name}" + }, + ) + is Resource.Loading -> LoadResult.Failure("Unexpected loading state from APIRepository.load") + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt new file mode 100644 index 00000000000..f2807c3f637 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt @@ -0,0 +1,202 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.MainAPI +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.tv.model.TvContentRail +import com.lagradost.cloudstream3.tv.model.TvHomeCatalog +import com.lagradost.cloudstream3.tv.model.TvMediaItem +import com.lagradost.cloudstream3.tv.model.TvMockCatalog +import com.lagradost.cloudstream3.tv.model.TvRailIds +import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi +import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi +import com.lagradost.cloudstream3.utils.DataStoreHelper + +/** + * Thin read-only bridge: APIRepository → immutable TV catalog. + * No Composables / Activity / navigation / focus / persistence writes. + * + * Documented APIRepository usage: + * - constructor(MainAPI) + * - hasMainPage, name + * - waitForHomeDelay() + * - getMainPage(page, nameIndex) → Resource<List<HomePageResponse?>> + * + * Provider selection mirrors HomeViewModel (read-only): DataStoreHelper.currentHomePage + * then first APIHolder.apis entry with hasMainPage. Does not write currentHomePage. + * + * Continue Watching (Phase 8): [TvContinueWatchingRepository] — pure read-only. + * Never calls HomeViewModel.getResumeWatching (that path can setKey DOWNLOAD_HEADER_CACHE). + * Empty CW → omit rail (do not mix mock CW into a real catalog). + * Full mock fallback still includes explicit demo CW rail. + */ +class TvHomeRepository( + private val continueWatchingRepository: TvContinueWatchingRepository = TvContinueWatchingRepository(), +) { + + sealed interface LoadResult { + data class Success(val catalog: TvHomeCatalog) : LoadResult + data class Empty(val providerName: String?) : LoadResult + data class Failure(val message: String) : LoadResult + } + + suspend fun loadHome(): LoadResult { + val api = resolveHomeApi() + ?: return LoadResult.Failure( + "No homepage provider available. Install/enable plugins, or load the demo catalog.", + ) + + if (api === noneApi || !api.hasMainPage) { + return LoadResult.Failure( + "Provider \"${api.name}\" has no homepage. Pick another provider or load the demo catalog.", + ) + } + + val repo = APIRepository(api) + repo.waitForHomeDelay() + + return when (val data = repo.getMainPage(page = 1, nameIndex = null)) { + is Resource.Success -> { + val pages = data.value.filterNotNull() + val lists = pages.flatMap { it.items }.filter { it.list.isNotEmpty() } + if (lists.isEmpty()) { + LoadResult.Empty(repo.name) + } else { + val catalog = buildCatalog(repo.name, lists) + if (catalog.rails.none { !it.isMock && it.items.isNotEmpty() }) { + LoadResult.Empty(repo.name) + } else { + LoadResult.Success(catalog) + } + } + } + + is Resource.Failure -> LoadResult.Failure( + data.errorString.ifBlank { "Failed to load homepage from ${repo.name}" }, + ) + + is Resource.Loading -> LoadResult.Failure("Unexpected loading state from getMainPage") + } + } + + fun mockFallbackCatalog(): TvHomeCatalog = TvMockCatalog.fullFallback + + /** + * Read-only CW refresh for an existing real catalog — never injects mock CW. + * Returns the catalog with Continue Watching rail replaced/removed. + */ + fun withRefreshedContinueWatching(catalog: TvHomeCatalog): TvHomeCatalog { + if (catalog.usingMockFallback) return catalog + val cw = continueWatchingRepository.loadContinueWatchingRail() + val withoutCw = catalog.rails.filterNot { it.id == TvRailIds.CONTINUE } + val rails = if (cw != null) listOf(cw) + withoutCw else withoutCw + return catalog.copy(rails = rails.filter { it.items.isNotEmpty() }) + } + + /** + * Read-only provider resolve — same sources HomeViewModel uses, no DataStore writes. + */ + fun resolveHomeApi(): MainAPI? { + val preferred = DataStoreHelper.currentHomePage + if (preferred == noneApi.name) return noneApi + if (preferred == randomApi.name) { + return APIHolder.apis.withLock { + APIHolder.apis.firstOrNull { it.hasMainPage } + } + } + getApiFromNameNull(preferred)?.takeIf { it.hasMainPage }?.let { return it } + return APIHolder.apis.withLock { + APIHolder.apis.firstOrNull { it.hasMainPage } + } + } + + private fun buildCatalog( + providerName: String, + lists: List, + ): TvHomeCatalog { + val allItems = lists.flatMap { it.list }.distinctBy { it.url } + + val trending = pickTrendingRail(lists) + val movies = pickMoviesRail(lists, allItems) + val anime = pickAnimeRail(lists, allItems) + + // Real CW only — omit when empty (never silent mock mixed into live catalog). + val continueWatching = continueWatchingRepository.loadContinueWatchingRail() + + val rails = listOfNotNull( + continueWatching, + trending, + movies ?: TvMockCatalog.movies, + anime ?: TvMockCatalog.anime, + ) + + val hero = pickHero(trending, movies, anime, allItems) + + return TvHomeCatalog( + hero = hero, + rails = rails.filter { it.items.isNotEmpty() }, + providerName = providerName, + usingMockFallback = false, + ) + } + + private fun pickTrendingRail(lists: List): TvContentRail? { + val named = lists.firstOrNull { list -> + val n = list.name.lowercase() + n.contains("trend") || n.contains("popular") || n.contains("hot") || + n.contains("top") || n.contains("featured") || n.contains("latest") + } ?: lists.firstOrNull() + return named?.let { + TvMediaMapper.toRail(TvRailIds.TRENDING, "Trending", it) + }?.takeIf { it.items.isNotEmpty() } + } + + private fun pickMoviesRail( + lists: List, + allItems: List, + ): TvContentRail? { + val named = lists.firstOrNull { it.name.contains("movie", ignoreCase = true) } + if (named != null && named.list.isNotEmpty()) { + return TvMediaMapper.toRail(TvRailIds.MOVIES, "Movies", named) + } + val filtered = allItems.filter { TvMediaMapper.isMovieRailType(it.type) } + if (filtered.isEmpty()) return null + return TvMediaMapper.toRailFromItems(TvRailIds.MOVIES, "Movies", filtered) + } + + private fun pickAnimeRail( + lists: List, + allItems: List, + ): TvContentRail? { + val named = lists.firstOrNull { it.name.contains("anime", ignoreCase = true) } + if (named != null && named.list.isNotEmpty()) { + return TvMediaMapper.toRail(TvRailIds.ANIME, "Anime", named) + } + val filtered = allItems.filter { TvMediaMapper.isAnimeType(it.type) } + if (filtered.isEmpty()) return null + return TvMediaMapper.toRailFromItems(TvRailIds.ANIME, "Anime", filtered) + } + + private fun pickHero( + trending: TvContentRail?, + movies: TvContentRail?, + anime: TvContentRail?, + allItems: List, + ): TvMediaItem { + val fromRails = sequenceOf(trending, movies, anime) + .filterNotNull() + .flatMap { it.items.asSequence() } + .firstOrNull { !it.posterUrl.isNullOrBlank() } + if (fromRails != null) return fromRails + + val fromDomain = allItems.firstOrNull { !it.posterUrl.isNullOrBlank() } + if (fromDomain != null) return TvMediaMapper.toMediaItem(fromDomain) + + // Explicit mock hero — never pretend it came from the API. + return TvMockCatalog.hero + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvMediaMapper.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvMediaMapper.kt new file mode 100644 index 00000000000..36950a86e34 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvMediaMapper.kt @@ -0,0 +1,103 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.AnimeSearchResponse +import com.lagradost.cloudstream3.HomePageList +import com.lagradost.cloudstream3.MovieSearchResponse +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvSeriesSearchResponse +import com.lagradost.cloudstream3.TvType +import com.lagradost.cloudstream3.tv.model.TvContentRail +import com.lagradost.cloudstream3.tv.model.TvMediaItem + +/** + * Null-safe mapping from domain search DTOs → immutable TV models. + * Does not mutate [SearchResponse] / [HomePageList]. + */ +object TvMediaMapper { + + fun toMediaItem(response: SearchResponse): TvMediaItem { + val poster = response.posterUrl?.takeIf { it.isNotBlank() } + val typeLabel = response.type?.name + val year = yearOf(response) + val rating = runCatching { + response.score?.toStringNull(minScore = 0.1, maxScore = 10) + }.getOrNull() + val subtitle = buildList { + typeLabel?.let { add(it) } + year?.let { add(it.toString()) } + response.quality?.name?.let { add(it) } + }.joinToString(" · ") + + return TvMediaItem( + id = response.id?.toString() + ?: "${response.apiName}:${response.url}".ifBlank { response.name }, + title = response.name.ifBlank { "Untitled" }, + subtitle = subtitle, + posterUrl = poster, + // SearchResponse has no backdrop field — poster is the legitimate fallback. + backdropUrl = poster, + year = year, + rating = rating, + typeLabel = typeLabel, + apiName = response.apiName.takeIf { it.isNotBlank() }, + url = response.url.takeIf { it.isNotBlank() }, + posterHeaders = response.posterHeaders?.toMap(), + isMock = false, + ) + } + + fun toRail( + id: String, + title: String, + list: HomePageList, + limit: Int = 24, + ): TvContentRail { + val items = list.list + .asSequence() + .map { toMediaItem(it) } + .distinctBy { it.id } + .take(limit) + .toList() + return TvContentRail( + id = id, + title = title, + items = items, + isMock = false, + ) + } + + fun toRailFromItems( + id: String, + title: String, + items: List, + limit: Int = 24, + ): TvContentRail { + return TvContentRail( + id = id, + title = title, + items = items.asSequence() + .map { toMediaItem(it) } + .distinctBy { it.id } + .take(limit) + .toList(), + isMock = false, + ) + } + + private fun yearOf(response: SearchResponse): Int? = when (response) { + is MovieSearchResponse -> response.year + is TvSeriesSearchResponse -> response.year + is AnimeSearchResponse -> response.year + else -> null + } + + fun isAnimeType(type: TvType?): Boolean = when (type) { + TvType.Anime, TvType.OVA, TvType.AnimeMovie, TvType.Cartoon -> true + else -> false + } + + fun isMovieRailType(type: TvType?): Boolean = when (type) { + TvType.Movie, TvType.Documentary, TvType.Torrent, TvType.Video -> true + else -> false + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchMapper.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchMapper.kt new file mode 100644 index 00000000000..72b312323e7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchMapper.kt @@ -0,0 +1,63 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.AnimeSearchResponse +import com.lagradost.cloudstream3.MovieSearchResponse +import com.lagradost.cloudstream3.SearchResponse +import com.lagradost.cloudstream3.TvSeriesSearchResponse +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvSearchResult + +/** + * Null-safe mapping from domain [SearchResponse] → immutable TV search models. + * Retains apiName + url for [TvContentRef] so Search → Details converges with Home. + * Does not mutate SearchResponse. + */ +object TvSearchMapper { + + /** + * Returns null when url/apiName are missing — never invent fake IDs for Details. + */ + fun toSearchResult(response: SearchResponse): TvSearchResult? { + val url = response.url.takeIf { it.isNotBlank() } ?: return null + val apiName = response.apiName.takeIf { it.isNotBlank() } ?: return null + val poster = response.posterUrl?.takeIf { it.isNotBlank() } + val typeLabel = response.type?.name + val year = yearOf(response) + val rating = runCatching { + response.score?.toStringNull(minScore = 0.1, maxScore = 10) + }.getOrNull() + val subtitle = buildList { + apiName.let { add(it) } + typeLabel?.let { add(it) } + year?.let { add(it.toString()) } + response.quality?.name?.let { add(it) } + }.joinToString(" · ") + + return TvSearchResult( + id = response.id?.toString() ?: "$apiName:$url", + title = response.name.ifBlank { "Untitled" }, + subtitle = subtitle, + posterUrl = poster, + year = year, + rating = rating, + typeLabel = typeLabel, + providerName = apiName, + posterHeaders = response.posterHeaders?.toMap(), + contentRef = TvContentRef( + url = url, + apiName = apiName, + title = response.name.ifBlank { "Untitled" }, + ), + ) + } + + fun toSearchResults(items: List): List = + items.mapNotNull { toSearchResult(it) }.distinctBy { it.id } + + private fun yearOf(response: SearchResponse): Int? = when (response) { + is MovieSearchResponse -> response.year + is TvSeriesSearchResponse -> response.year + is AnimeSearchResponse -> response.year + else -> null + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchRepository.kt new file mode 100644 index 00000000000..2ce2c4caa99 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchRepository.kt @@ -0,0 +1,177 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.APIHolder +import com.lagradost.cloudstream3.amap +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.tv.model.TvSearchCatalog +import com.lagradost.cloudstream3.tv.model.TvSearchResult +import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.utils.DataStoreHelper +import kotlinx.coroutines.ensureActive +import kotlin.coroutines.coroutineContext + +/** + * Thin read-only bridge: multi-provider APIRepository.search → immutable TV results. + * No Composables / Activity / navigation / focus / DataStore writes / search history. + * + * Documented APIRepository search usage (inspected, not invented): + * - constructor(MainAPI) + * - search(query, page) → Resource<SearchResponseList> (items + hasNext) + * - quickSearch(query) → Resource<SearchResponseList> (hasQuickSearch providers only) + * - Empty query → Success(empty list) without provider call + * - Timeout via api.searchTimeoutMs / safeApiCall + * + * Multi-provider flow mirrors [com.lagradost.cloudstream3.ui.search.SearchViewModel]: + * - repos from APIHolder.apis → APIRepository + * - Filter by DataStoreHelper.searchPreferenceProviders when non-empty (read-only) + * - Parallel amap; page=1 full search (not quickSearch) on explicit submit + * - Generation / cancel handled by caller (ViewModel job cancel) + * - Partial provider Failure: skip that provider, keep successes (same as SearchViewModel) + * - Does NOT write SEARCH_HISTORY_KEY / DataStore search history + * + * SearchResponse retains apiName + url from providers; [TvSearchMapper] copies both into + * [com.lagradost.cloudstream3.tv.model.TvContentRef] for Details convergence with Home. + */ +class TvSearchRepository { + + sealed interface SearchResult { + data class Success(val catalog: TvSearchCatalog) : SearchResult + data class Empty( + val query: String, + val providerCount: Int, + val failedProviderCount: Int, + ) : SearchResult + data class Failure(val query: String, val message: String) : SearchResult + } + + /** + * Resolve providers the same way mobile search prefers: saved searchPreferenceProviders, + * else all loaded APIs. Read-only — never writes provider prefs. + */ + fun resolveSearchRepos(): List { + val all = APIHolder.apis.withLock { APIHolder.apis.map { APIRepository(it) } } + val preferred = DataStoreHelper.searchPreferenceProviders + if (preferred.isEmpty()) return all + val set = preferred.toSet() + val filtered = all.filter { set.contains(it.name) } + return filtered.ifEmpty { all } + } + + /** + * Full multi-provider search (page 1). Call from IO dispatcher. + * [isActive] should return false when a newer search superseded this one. + */ + suspend fun search( + query: String, + isActive: () -> Boolean = { true }, + ): SearchResult { + val trimmed = query.trim() + if (trimmed.length <= 1) { + return SearchResult.Failure( + query = trimmed, + message = "Enter at least 2 characters to search.", + ) + } + + val repos = resolveSearchRepos() + if (repos.isEmpty()) { + return SearchResult.Failure( + query = trimmed, + message = "No search providers available. Install/enable plugins, then retry.", + ) + } + + if (!isActive()) { + return SearchResult.Failure(trimmed, "Search cancelled") + } + + var failed = 0 + var succeeded = 0 + + // Parallel per provider — same amap pattern as SearchViewModel. + val perProvider = repos.amap { repo -> + coroutineContext.ensureActive() + if (!isActive()) return@amap ProviderOutcome.Cancelled + when (val data = repo.search(trimmed, page = 1)) { + is Resource.Success -> { + val mapped = TvSearchMapper.toSearchResults(data.value.items) + ProviderOutcome.Ok(mapped) + } + is Resource.Failure -> ProviderOutcome.Fail( + data.errorString.ifBlank { "Failed: ${repo.name}" }, + ) + is Resource.Loading -> ProviderOutcome.Fail("Unexpected loading from ${repo.name}") + } + } + + if (!isActive()) { + return SearchResult.Failure(trimmed, "Search cancelled") + } + + for (outcome in perProvider) { + when (outcome) { + is ProviderOutcome.Ok -> { + succeeded++ + } + is ProviderOutcome.Fail -> failed++ + ProviderOutcome.Cancelled -> { + return SearchResult.Failure(trimmed, "Search cancelled") + } + } + } + + // Round-robin merge like SearchViewModel.bundleSearch for relevance. + val bundled = bundleRoundRobin(perProvider) + + return when { + bundled.isNotEmpty() -> SearchResult.Success( + TvSearchCatalog( + query = trimmed, + results = bundled, + providerCount = repos.size, + failedProviderCount = failed, + ), + ) + succeeded == 0 && failed > 0 -> SearchResult.Failure( + query = trimmed, + message = "All $failed provider(s) failed. Check network/plugins, then retry.", + ) + else -> SearchResult.Empty( + query = trimmed, + providerCount = repos.size, + failedProviderCount = failed, + ) + } + } + + /** + * Round-robin merge across successful provider lists — mirrors SearchViewModel.bundleSearch + * so the first hit from each provider rises toward the top. + */ + private fun bundleRoundRobin(outcomes: List): List { + val lists = outcomes.mapNotNull { (it as? ProviderOutcome.Ok)?.items }.filter { it.isNotEmpty() } + if (lists.isEmpty()) return emptyList() + if (lists.size == 1) return lists.first().distinctBy { it.id } + + val out = ArrayList() + var index = 0 + while (true) { + var added = 0 + for (sub in lists) { + if (sub.size > index) { + out.add(sub[index]) + added++ + } + } + if (added == 0) break + index++ + } + return out.distinctBy { it.id } + } + + private sealed interface ProviderOutcome { + data class Ok(val items: List) : ProviderOutcome + data class Fail(val message: String) : ProviderOutcome + data object Cancelled : ProviderOutcome + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvWatchlistRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvWatchlistRepository.kt new file mode 100644 index 00000000000..8302f0a73bf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvWatchlistRepository.kt @@ -0,0 +1,150 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.tv.model.TvWatchlistCatalog +import com.lagradost.cloudstream3.tv.model.TvWatchlistItem +import com.lagradost.cloudstream3.tv.model.TvWatchlistSection +import com.lagradost.cloudstream3.ui.WatchType +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites +import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds +import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData +import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount +import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState + +/** + * Pure read-only Watchlist / Library adapter (Local list only). + * + * Exact source APIs (same data [com.lagradost.cloudstream3.syncproviders.providers.LocalList] reads): + * - [DataStoreHelper.getAllWatchStateIds] + * - [DataStoreHelper.getResultWatchState] + * - [DataStoreHelper.getBookmarkedData] + * - [DataStoreHelper.getAllFavorites] + * - [DataStoreHelper.getCurrentAccount] / [DataStoreHelper.currentAccount] (identity only) + * + * Intentionally does **not** call SyncRepo / MAL / AniList / Simkl / Kitsu (auth + network), + * does **not** write LAST_SYNC_API_KEY or librarySortingMode, and does **not** mutate + * requireLibraryRefresh. Subscriptions omitted (LocalList already hides them on TV). + */ +class TvWatchlistRepository { + + sealed interface LoadResult { + data class Success(val catalog: TvWatchlistCatalog) : LoadResult + data class Empty(val catalog: TvWatchlistCatalog) : LoadResult + data class Failure(val message: String) : LoadResult + } + + fun loadWatchlist(): LoadResult { + return try { + val account = getCurrentAccount() + val accountKey = DataStoreHelper.currentAccount + val accountName = account?.name + + val sections = buildSections() + val catalog = TvWatchlistCatalog( + sections = sections, + accountKey = accountKey, + accountName = accountName, + ) + if (sections.isEmpty() || sections.all { it.items.isEmpty() }) { + LoadResult.Empty(catalog) + } else { + LoadResult.Success(catalog) + } + } catch (t: Throwable) { + LoadResult.Failure(t.message ?: "Failed to read Library / Watchlist") + } + } + + private fun buildSections(): List { + val watchStatusIds = getAllWatchStateIds()?.map { id -> + id to getResultWatchState(id) + }?.distinctBy { it.first }.orEmpty() + + val byType = linkedMapOf>() + WatchType.entries.filter { it != WatchType.NONE }.forEach { byType[it] = mutableListOf() } + + for ((id, type) in watchStatusIds) { + if (type == WatchType.NONE) continue + val data = getBookmarkedData(id) ?: continue + val item = mapBookmark(data, statusLabel = watchTypeLabel(type)) ?: continue + byType.getOrPut(type) { mutableListOf() }.add(item) + } + + byType.values.forEach { list -> + list.sortByDescending { it.latestUpdatedTime } + } + + val watchSections = WatchType.entries + .filter { it != WatchType.NONE } + .mapNotNull { type -> + val items = byType[type].orEmpty() + if (items.isEmpty()) return@mapNotNull null + TvWatchlistSection( + id = "watch-${type.internalId}", + title = watchTypeLabel(type), + items = items, + ) + } + + val favorites = getAllFavorites().mapNotNull { fav -> + val url = fav.url.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val apiName = fav.apiName.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val title = fav.name.takeIf { it.isNotBlank() } ?: return@mapNotNull null + TvWatchlistItem( + id = "fav-${fav.id ?: "${apiName}:$url"}", + title = title, + url = url, + apiName = apiName, + posterUrl = fav.posterUrl?.takeIf { it.isNotBlank() }, + year = fav.year, + typeLabel = fav.type?.name, + watchStatusLabel = "Favorites", + latestUpdatedTime = fav.latestUpdatedTime, + posterHeaders = fav.posterHeaders?.toMap(), + ) + }.sortedByDescending { it.latestUpdatedTime } + + val favSection = if (favorites.isEmpty()) { + null + } else { + TvWatchlistSection( + id = "favorites", + title = "Favorites", + items = favorites, + ) + } + + return watchSections + listOfNotNull(favSection) + } + + private fun mapBookmark( + data: DataStoreHelper.BookmarkedData, + statusLabel: String, + ): TvWatchlistItem? { + val url = data.url.takeIf { it.isNotBlank() } ?: return null + val apiName = data.apiName.takeIf { it.isNotBlank() } ?: return null + val title = data.name.takeIf { it.isNotBlank() } ?: return null + return TvWatchlistItem( + id = "bm-${data.id ?: "${apiName}:$url"}", + title = title, + url = url, + apiName = apiName, + posterUrl = data.posterUrl?.takeIf { it.isNotBlank() }, + year = data.year, + typeLabel = data.type?.name, + watchStatusLabel = statusLabel, + latestUpdatedTime = data.latestUpdatedTime, + posterHeaders = data.posterHeaders?.toMap(), + ) + } + + /** Labels match CloudStream strings.xml (type_* / favorites_list_name). */ + private fun watchTypeLabel(type: WatchType): String = when (type) { + WatchType.WATCHING -> "Watching" + WatchType.COMPLETED -> "Completed" + WatchType.ONHOLD -> "On-Hold" + WatchType.DROPPED -> "Dropped" + WatchType.PLANTOWATCH -> "Plan to Watch" + WatchType.NONE -> "None" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt new file mode 100644 index 00000000000..36e996f738f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt @@ -0,0 +1,447 @@ +package com.lagradost.cloudstream3.tv.details + +import androidx.activity.compose.BackHandler +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Glow +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.SurfaceDefaults +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.model.TvAvailabilityClassifier +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvDetailsAction +import com.lagradost.cloudstream3.tv.model.TvDetailsContent +import com.lagradost.cloudstream3.tv.model.TvDetailsUiState +import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest +import com.lagradost.cloudstream3.tv.model.isSeriesOrAnime +import com.lagradost.cloudstream3.tv.model.watchNowDisabledReason + +/** + * Cinematic Details screen. Loads via [TvDetailsViewModel] / TvDetailsRepository. + * Movies: Watch Now → Activity → TvPlaybackBridge. + * Series/Anime: season/episode selector → Play Episode → same bridge path. + * Live/Torrent: explicit unsupported. Mock never plays. + */ +@Composable +fun TvDetailsScreen( + ref: TvContentRef, + onBack: () -> Unit, + modifier: Modifier = Modifier, + onPlaybackRequest: (TvPlaybackRequest) -> Unit = {}, + viewModel: TvDetailsViewModel = viewModel( + key = "tv-details:${ref.apiName}|${ref.url}", + ), +) { + val uiState by viewModel.state.collectAsState() + + LaunchedEffect(ref) { + viewModel.onPlaybackRequest = onPlaybackRequest + viewModel.bind(ref) + } + + BackHandler(onBack = onBack) + + when (val state = uiState) { + is TvDetailsUiState.Loading -> TvDetailsLoadingPane( + titleHint = state.titleHint ?: ref.title.takeIf { it.isNotBlank() }, + onBack = onBack, + modifier = modifier, + ) + is TvDetailsUiState.Content -> TvDetailsContentPane( + content = state, + onAction = viewModel::onAction, + onBack = onBack, + modifier = modifier, + ) + is TvDetailsUiState.Error -> { + val status = TvAvailabilityClassifier.fromDetailsFailure(state.message) + TvDetailsErrorPane( + availabilityTitle = status.title, + message = state.message, + titleHint = state.titleHint ?: ref.title.takeIf { it.isNotBlank() }, + onRetry = { viewModel.onAction(TvDetailsAction.Retry) }, + onBack = onBack, + modifier = modifier, + ) + } + } +} + +@Composable +private fun TvDetailsContentPane( + content: TvDetailsUiState.Content, + onAction: (TvDetailsAction) -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val details = content.details + val context = LocalContext.current + val primaryFocus = remember { FocusRequester() } + val placeholder = ColorPainter(Color(0xFF1A1A1E)) + val imageUrl = details.backdropUrl?.takeIf { it.isNotBlank() } + ?: details.posterUrl?.takeIf { it.isNotBlank() } + val backdropRequest = ImageRequest.Builder(context) + .data(imageUrl) + .size(1280, 720) + .crossfade(true) + .httpHeaders(detailsHeaders(details)) + .build() + val posterRequest = ImageRequest.Builder(context) + .data(details.posterUrl?.takeIf { it.isNotBlank() }) + .size(400, 600) + .crossfade(true) + .httpHeaders(detailsHeaders(details)) + .build() + + var restoreEpisodeFocus by remember { mutableStateOf(false) } + + LaunchedEffect(details.url, details.variantLabel) { + // Movies / unsupported: focus primary CTA. Series/Anime: focus Play Episode. + runCatching { primaryFocus.requestFocus() } + } + + Box(modifier = modifier.fillMaxSize()) { + AsyncImage( + model = backdropRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = placeholder, + error = placeholder, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.92f), + 0.55f to Color.Black.copy(alpha = 0.72f), + 1f to Color.Black.copy(alpha = 0.45f), + ), + ), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.35f), + 0.5f to Color.Transparent, + 1f to MaterialTheme.colorScheme.background.copy(alpha = 0.95f), + ), + ), + ) + + Row( + modifier = Modifier + .fillMaxSize() + .padding(start = 36.dp, end = 36.dp, top = 28.dp, bottom = 28.dp) + .verticalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(28.dp), + ) { + Surface( + modifier = Modifier + .width(180.dp) + .aspectRatio(2f / 3f), + shape = RoundedCornerShape(12.dp), + colors = SurfaceDefaults.colors( + containerColor = Color(0xFF2A2A2E), + ), + ) { + AsyncImage( + model = posterRequest, + contentDescription = details.title, + contentScale = ContentScale.Crop, + placeholder = placeholder, + error = placeholder, + modifier = Modifier.fillMaxSize(), + ) + } + + Column( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .widthIn(max = 780.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = buildHeaderLabel(details), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = details.title, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = buildMetadataLine(details), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (details.genres.isNotEmpty()) { + Text( + text = details.genres.joinToString(" · "), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + if (details.synopsis.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = details.synopsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.92f), + maxLines = 6, + overflow = TextOverflow.Ellipsis, + ) + } + if (details.actors.isNotEmpty()) { + Text( + text = "Cast: " + details.actors.take(8).joinToString(", "), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val disabledReason = details.watchNowDisabledReason() + val playEnabled = disabledReason == null && when { + details.variantLabel == "Movie" -> true + details.isSeriesOrAnime() -> content.selectedEpisode?.isPlayable == true + else -> false + } + val ctaLabel = when { + details.comingSoon -> "Coming Soon" + details.isSeriesOrAnime() -> "Play Episode" + else -> "Watch Now" + } + Button( + onClick = { + if (details.isSeriesOrAnime()) { + onAction(TvDetailsAction.PlaySelectedEpisode) + } else { + onAction(TvDetailsAction.WatchNow) + } + }, + enabled = playEnabled, + modifier = Modifier.focusRequester(primaryFocus), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.HeroButtonFocused), + glow = ButtonDefaults.glow( + focusedGlow = Glow( + elevationColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + elevation = 12.dp, + ), + ), + ) { + Text(ctaLabel) + } + Button( + onClick = onBack, + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text("Back") + } + } + Text( + text = details.watchNowDisabledReason() + ?: when { + details.isSeriesOrAnime() -> { + val ep = content.selectedEpisode + if (ep != null) { + "Playing ${ep.titleLine} via existing GeneratorPlayer." + } else { + "Select an episode, then Play Episode." + } + } + else -> "Watch Now starts existing CloudStream playback (GeneratorPlayer)." + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + ) + + if (details.hasEpisodeSelector) { + Spacer(Modifier.height(8.dp)) + TvEpisodeSelector( + content = content, + onAction = onAction, + restoreEpisodeFocus = restoreEpisodeFocus, + onRestoreConsumed = { restoreEpisodeFocus = false }, + ) + } + } + } + } +} + +@Composable +private fun TvDetailsLoadingPane( + titleHint: String?, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val backFocus = remember { FocusRequester() } + LaunchedEffect(Unit) { + runCatching { backFocus.requestFocus() } + } + Column( + modifier = modifier + .fillMaxSize() + .padding(48.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = titleHint ?: "Loading details…", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = "Fetching via APIRepository.load…", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onBack, + modifier = Modifier.focusRequester(backFocus), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text("Back") + } + } +} + +@Composable +private fun TvDetailsErrorPane( + availabilityTitle: String, + message: String, + titleHint: String?, + onRetry: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val retryFocus = remember { FocusRequester() } + LaunchedEffect(message) { + runCatching { retryFocus.requestFocus() } + } + Column( + modifier = modifier + .fillMaxSize() + .padding(48.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = availabilityTitle, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + if (!titleHint.isNullOrBlank()) { + Text( + text = titleHint, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + text = message + "\n\nRetry or Back. No silent swap to other titles.", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Button( + onClick = onRetry, + modifier = Modifier.focusRequester(retryFocus), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text("Retry") + } + Button( + onClick = onBack, + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text("Back") + } + } + } +} + +private fun detailsHeaders(details: TvDetailsContent): NetworkHeaders = + NetworkHeaders.Builder().also { headers -> + headers["User-Agent"] = USER_AGENT + details.posterHeaders?.forEach { (k, v) -> headers[k] = v } + }.build() + +private fun buildHeaderLabel(details: TvDetailsContent): String { + val parts = buildList { + add(details.variantLabel.uppercase()) + details.typeLabel?.let { add(it) } + details.apiName.takeIf { it.isNotBlank() }?.let { add(it) } + if (details.comingSoon) add("COMING SOON") + } + return parts.joinToString(" · ") +} + +private fun buildMetadataLine(details: TvDetailsContent): String { + val parts = buildList { + details.year?.let { add(it.toString()) } + details.rating?.let { add("★ $it") } + details.runtime?.let { add(it) } + details.contentRating?.let { add(it) } + details.showStatus?.let { add(it) } + details.episodeCount?.let { add("$it episodes") } + } + return parts.joinToString(" • ") +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt new file mode 100644 index 00000000000..0ce72694d8e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt @@ -0,0 +1,184 @@ +package com.lagradost.cloudstream3.tv.details + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.tv.data.TvDetailsRepository +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvDetailsAction +import com.lagradost.cloudstream3.tv.model.TvDetailsContent +import com.lagradost.cloudstream3.tv.model.TvDetailsUiState +import com.lagradost.cloudstream3.tv.data.TvContinueWatchingResume +import com.lagradost.cloudstream3.tv.model.TvEpisodeDefaults +import com.lagradost.cloudstream3.tv.model.TvResumeHint +import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Lifecycle-aware Details state holder (MVI / [StateContainer]). + * Loads once per [TvContentRef]; selection (dub / season / episode) lives in UiState only — + * no FocusRequester / Activity in state. + * + * Playback: builds immutable [TvPlaybackRequest] and forwards via [onPlaybackRequest] + * (Activity → TvPlaybackBridge). No DataStore / PosDur writes. + * + * Phase 9: optional [TvContentRef.resumeHint] restores season/episode selection after load + * (read-only match). Does not auto-play from Details — Home CW orchestrates fromEpisode. + */ +class TvDetailsViewModel( + private val repository: TvDetailsRepository = TvDetailsRepository(), + private val resumeResolver: TvContinueWatchingResume = TvContinueWatchingResume(repository), +) : ViewModel(), + StateContainer by DefaultStateContainer(TvDetailsUiState.Loading()), + ActionHandler { + + private var loadJob: Job? = null + private var boundRef: TvContentRef? = null + + /** Activity host callback — receives immutable request only. */ + var onPlaybackRequest: ((TvPlaybackRequest) -> Unit)? = null + + fun bind(ref: TvContentRef) { + if (boundRef == ref && state.value !is TvDetailsUiState.Error) { + if (state.value is TvDetailsUiState.Content || state.value is TvDetailsUiState.Loading) { + return + } + } + boundRef = ref + loadDetails(ref) + } + + override fun onAction(action: TvDetailsAction) { + when (action) { + TvDetailsAction.Retry -> boundRef?.let { loadDetails(it) } + TvDetailsAction.Back -> Unit + TvDetailsAction.WatchNow -> emitMoviePlayback() + TvDetailsAction.PlaySelectedEpisode -> emitEpisodePlayback() + is TvDetailsAction.SelectDubStatus -> selectDub(action.dubStatusId) + is TvDetailsAction.SelectSeason -> selectSeason(action.seasonIndex) + is TvDetailsAction.SelectEpisode -> selectEpisode(action.episodeId) + } + } + + private fun emitMoviePlayback() { + val content = state.value as? TvDetailsUiState.Content ?: return + if (content.details.variantLabel != "Movie") return + onPlaybackRequest?.invoke(TvPlaybackRequest.fromDetails(content.details)) + } + + private fun emitEpisodePlayback() { + val content = state.value as? TvDetailsUiState.Content ?: return + if (!content.details.hasEpisodeSelector) return + val episode = content.selectedEpisode ?: return + if (!episode.isPlayable) return + onPlaybackRequest?.invoke( + TvPlaybackRequest.fromEpisode(content.details, episode), + ) + } + + private fun selectDub(dubStatusId: Int) { + updateState { + val content = this as? TvDetailsUiState.Content ?: return@updateState this + if (content.selectedDubStatusId == dubStatusId) return@updateState content + val seasons = content.details.seasonsForDub(dubStatusId) + val seasonIndex = TvEpisodeDefaults.pickSeasonIndex(seasons) + val season = seasons.firstOrNull { it.seasonIndex == seasonIndex } + val episodeId = TvEpisodeDefaults.pickEpisodeId(season) + content.copy( + selectedDubStatusId = dubStatusId, + selectedSeasonIndex = seasonIndex, + selectedEpisodeId = episodeId, + ) + } + } + + private fun selectSeason(seasonIndex: Int) { + updateState { + val content = this as? TvDetailsUiState.Content ?: return@updateState this + if (content.selectedSeasonIndex == seasonIndex) return@updateState content + val season = content.details.seasonsForDub(content.selectedDubStatusId) + .firstOrNull { it.seasonIndex == seasonIndex } + val episodeId = TvEpisodeDefaults.pickEpisodeId(season) + content.copy( + selectedSeasonIndex = seasonIndex, + selectedEpisodeId = episodeId, + ) + } + } + + private fun selectEpisode(episodeId: Int) { + updateState { + val content = this as? TvDetailsUiState.Content ?: return@updateState this + content.copy(selectedEpisodeId = episodeId) + } + } + + private fun loadDetails(ref: TvContentRef) { + loadJob?.cancel() + loadJob = viewModelScope.launch { + updateState { + TvDetailsUiState.Loading(titleHint = ref.title.takeIf { it.isNotBlank() }) + } + val result = try { + withContext(Dispatchers.IO) { repository.load(ref) } + } catch (t: Throwable) { + logError(t) + TvDetailsRepository.LoadResult.Failure( + t.message ?: "Unexpected error loading details", + ) + } + updateState { + when (result) { + is TvDetailsRepository.LoadResult.Success -> + contentWithResumeRestore(result.details, ref.resumeHint) + + is TvDetailsRepository.LoadResult.Failure -> + TvDetailsUiState.Error( + message = result.message, + titleHint = ref.title.takeIf { it.isNotBlank() }, + ) + } + } + } + } + + /** + * Apply CW resume hint when possible: episodeId first, else season+episode. + * Falls back to Phase 6 defaults — never invents episodes or writes DataStore. + */ + private fun contentWithResumeRestore( + details: TvDetailsContent, + hint: TvResumeHint?, + ): TvDetailsUiState.Content { + if (hint == null || !details.hasEpisodeSelector || !hint.hasExactEpisode) { + return TvDetailsUiState.Content( + details = details, + selectedDubStatusId = details.defaultDubStatusId, + selectedSeasonIndex = details.defaultSeasonIndex, + selectedEpisodeId = details.defaultEpisodeId, + ) + } + val matched = resumeResolver.matchEpisode(details, hint) + if (matched == null) { + return TvDetailsUiState.Content( + details = details, + selectedDubStatusId = details.defaultDubStatusId, + selectedSeasonIndex = details.defaultSeasonIndex, + selectedEpisodeId = details.defaultEpisodeId, + ) + } + val seasonIndex = matched.seasonIndex ?: 0 + return TvDetailsUiState.Content( + details = details, + selectedDubStatusId = matched.dubStatusId, + selectedSeasonIndex = seasonIndex, + selectedEpisodeId = matched.id, + ) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvEpisodeSelector.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvEpisodeSelector.kt new file mode 100644 index 00000000000..827349520c4 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvEpisodeSelector.kt @@ -0,0 +1,322 @@ +package com.lagradost.cloudstream3.tv.details + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.model.TvDetailsAction +import com.lagradost.cloudstream3.tv.model.TvDetailsUiState +import com.lagradost.cloudstream3.tv.model.TvDubGroup +import com.lagradost.cloudstream3.tv.model.TvEpisode +import com.lagradost.cloudstream3.tv.model.TvSeason + +/** + * 10ft season + episode selector for Series / Anime. + * Selection events go to ViewModel — FocusRequester stays in composition only. + * Uses LazyRow / LazyColumn + focusRestorer (Phase 2 pattern). + */ +@Composable +fun TvEpisodeSelector( + content: TvDetailsUiState.Content, + onAction: (TvDetailsAction) -> Unit, + modifier: Modifier = Modifier, + restoreEpisodeFocus: Boolean = false, + onRestoreConsumed: () -> Unit = {}, +) { + val seasons = content.visibleSeasons + val episodes = content.visibleEpisodes + if (seasons.isEmpty()) { + Text( + text = "No episodes available", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = modifier.padding(top = 8.dp), + ) + return + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (content.showDubSelector) { + TvDubRow( + groups = content.details.dubGroups, + selectedId = content.selectedDubStatusId, + onSelect = { onAction(TvDetailsAction.SelectDubStatus(it)) }, + ) + } + TvSeasonRow( + seasons = seasons, + selectedIndex = content.selectedSeasonIndex, + onSelect = { onAction(TvDetailsAction.SelectSeason(it)) }, + ) + Text( + text = content.selectedSeason?.label?.let { "$it · ${episodes.size} episodes" } + ?: "${episodes.size} episodes", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + TvEpisodeList( + episodes = episodes, + selectedId = content.selectedEpisodeId, + onSelect = { onAction(TvDetailsAction.SelectEpisode(it)) }, + onPlay = { id -> + onAction(TvDetailsAction.SelectEpisode(id)) + onAction(TvDetailsAction.PlaySelectedEpisode) + }, + restoreFocus = restoreEpisodeFocus, + onRestoreConsumed = onRestoreConsumed, + ) + } +} + +@Composable +private fun TvDubRow( + groups: List, + selectedId: Int?, + onSelect: (Int) -> Unit, +) { + val focusRequesters = remember(groups.map { it.dubStatusId }) { + List(groups.size) { FocusRequester() } + } + val selectedPos = groups.indexOfFirst { it.dubStatusId == selectedId }.coerceAtLeast(0) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .focusRestorer(focusRequesters.getOrNull(selectedPos) ?: FocusRequester.Default) + .focusGroup(), + ) { + itemsIndexed(groups, key = { _, g -> g.dubStatusId }) { index, group -> + val selected = group.dubStatusId == selectedId + SelectorChip( + label = group.label.ifBlank { "Default" }, + selected = selected, + onClick = { onSelect(group.dubStatusId) }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } +} + +@Composable +private fun TvSeasonRow( + seasons: List, + selectedIndex: Int?, + onSelect: (Int) -> Unit, +) { + val focusRequesters = remember(seasons.map { it.seasonIndex }) { + List(seasons.size) { FocusRequester() } + } + val listState = rememberLazyListState() + val selectedPos = seasons.indexOfFirst { it.seasonIndex == selectedIndex }.coerceAtLeast(0) + + LaunchedEffect(selectedIndex, seasons.size) { + if (seasons.isNotEmpty()) { + listState.animateScrollToItem(selectedPos.coerceIn(0, seasons.lastIndex)) + } + } + + LazyRow( + state = listState, + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .focusRestorer(focusRequesters.getOrNull(selectedPos) ?: FocusRequester.Default) + .focusGroup(), + ) { + itemsIndexed(seasons, key = { _, s -> s.seasonIndex }) { index, season -> + val selected = season.seasonIndex == selectedIndex + SelectorChip( + label = season.label, + selected = selected, + onClick = { onSelect(season.seasonIndex) }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } +} + +@Composable +private fun TvEpisodeList( + episodes: List, + selectedId: Int?, + onSelect: (Int) -> Unit, + onPlay: (Int) -> Unit, + restoreFocus: Boolean, + onRestoreConsumed: () -> Unit, +) { + if (episodes.isEmpty()) { + Text( + text = "No episodes in this season", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + return + } + val focusRequesters = remember(episodes.map { it.id }) { + List(episodes.size) { FocusRequester() } + } + val listState = rememberLazyListState() + val selectedPos = episodes.indexOfFirst { it.id == selectedId }.let { + if (it >= 0) it else 0 + } + + LaunchedEffect(restoreFocus, selectedId, episodes.size) { + if (restoreFocus && focusRequesters.isNotEmpty()) { + val idx = selectedPos.coerceIn(0, focusRequesters.lastIndex) + listState.scrollToItem(idx) + runCatching { focusRequesters[idx].requestFocus() } + onRestoreConsumed() + } + } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 4.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 280.dp) + .focusRestorer(focusRequesters.getOrNull(selectedPos) ?: FocusRequester.Default) + .focusGroup(), + ) { + itemsIndexed(episodes, key = { _, ep -> ep.id }) { index, episode -> + EpisodeRow( + episode = episode, + selected = episode.id == selectedId, + onFocus = { onSelect(episode.id) }, + onClick = { + onSelect(episode.id) + if (episode.isPlayable) onPlay(episode.id) + }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } +} + +@Composable +private fun SelectorChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + modifier = modifier.widthIn(min = 96.dp), + scale = ClickableSurfaceDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f) + }, + focusedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun EpisodeRow( + episode: TvEpisode, + selected: Boolean, + onFocus: () -> Unit, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + onClick = onClick, + enabled = episode.isPlayable, + modifier = modifier + .fillMaxWidth() + .onFocusChanged { if (it.isFocused) onFocus() }, + scale = ClickableSurfaceDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + colors = ClickableSurfaceDefaults.colors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.55f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + }, + focusedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.4f), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.45f), + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = episode.titleLine, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val subtitle = buildList { + episode.scoreLabel?.let { add("★ $it") } + episode.runTime?.takeIf { it > 0 }?.let { add("${it}m") } + if (!episode.isPlayable) add("Unavailable") + episode.description?.takeIf { it.isNotBlank() }?.let { add(it) } + }.joinToString(" • ") + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt new file mode 100644 index 00000000000..e7f8b07bf22 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt @@ -0,0 +1,88 @@ +package com.lagradost.cloudstream3.tv.home + +import androidx.compose.foundation.focusGroup +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.unit.dp +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.lagradost.cloudstream3.tv.components.TvMediaCard +import com.lagradost.cloudstream3.tv.model.TvContentRail +import com.lagradost.cloudstream3.tv.model.TvMediaItem + +/** + * Horizontal content rail with per-rail focus memory. + * Skipped when [rail.items] is empty so empty rails never trap D-pad focus. + * [lastFocusedIndex] is owned by [TvHomeFocusState]; coerced to dynamic item count. + */ +@Composable +fun TvContentRail( + rail: TvContentRail, + lastFocusedIndex: Int, + onFocusedIndexChanged: (Int) -> Unit, + modifier: Modifier = Modifier, + onItemClick: (TvMediaItem) -> Unit = {}, + onItemLongClick: ((TvMediaItem) -> Unit)? = null, + restoreFocus: Boolean = false, + onRestoreConsumed: () -> Unit = {}, +) { + if (rail.items.isEmpty()) return + + val safeIndex = lastFocusedIndex.coerceIn(0, rail.items.lastIndex) + val focusRequesters = remember(rail.id, rail.items.size) { + List(rail.items.size) { FocusRequester() } + } + val listState = rememberLazyListState() + + LaunchedEffect(restoreFocus, rail.id, safeIndex, rail.items.size) { + if (restoreFocus && focusRequesters.isNotEmpty()) { + listState.scrollToItem(safeIndex) + runCatching { focusRequesters[safeIndex].requestFocus() } + onRestoreConsumed() + } + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = if (rail.isMock) "${rail.title} · Demo" else rail.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 8.dp), + ) + LazyRow( + state = listState, + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier + .fillMaxWidth() + .focusRestorer(focusRequesters.getOrNull(safeIndex) ?: FocusRequester.Default) + .focusGroup(), + ) { + itemsIndexed(rail.items, key = { _, item -> item.id }) { index, item -> + TvMediaCard( + item = item, + onClick = { onItemClick(item) }, + onLongClick = onItemLongClick?.let { handler -> { handler(item) } }, + onFocused = { onFocusedIndexChanged(index) }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt new file mode 100644 index 00000000000..407e8ecba71 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt @@ -0,0 +1,179 @@ +package com.lagradost.cloudstream3.tv.home + +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.Glow +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.crossfade +import com.lagradost.cloudstream3.USER_AGENT +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.model.TvMediaItem + +@Composable +fun TvHeroSection( + hero: TvMediaItem, + watchFocusRequester: FocusRequester, + modifier: Modifier = Modifier, + detailsEnabled: Boolean = true, + /** Explicit label — real playable / Details / Demo. Never silent. */ + watchLabel: String = "Watch Now", + onWatchNow: () -> Unit = {}, + onDetails: () -> Unit = {}, +) { + val context = LocalContext.current + val placeholder = ColorPainter(Color(0xFF1A1A1E)) + val imageUrl = hero.backdropUrl?.takeIf { it.isNotBlank() } + ?: hero.posterUrl?.takeIf { it.isNotBlank() } + val request = ImageRequest.Builder(context) + .data(imageUrl) + .size(1280, 720) + .crossfade(true) + .httpHeaders( + NetworkHeaders.Builder().also { headers -> + headers["User-Agent"] = USER_AGENT + hero.posterHeaders?.forEach { (k, v) -> headers[k] = v } + }.build(), + ) + .build() + + Box( + modifier = modifier + .fillMaxWidth() + .height(320.dp), + ) { + AsyncImage( + model = request, + contentDescription = null, + contentScale = ContentScale.Crop, + placeholder = placeholder, + error = placeholder, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.horizontalGradient( + 0f to Color.Black.copy(alpha = 0.88f), + 0.45f to Color.Black.copy(alpha = 0.55f), + 0.85f to Color.Transparent, + ), + ), + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + 0f to Color.Transparent, + 0.7f to Color.Transparent, + 1f to MaterialTheme.colorScheme.background, + ), + ), + ) + Column( + modifier = Modifier + .fillMaxHeight() + .widthIn(max = 560.dp) + .padding(start = 8.dp, end = 24.dp, top = 28.dp, bottom = 20.dp), + verticalArrangement = Arrangement.Bottom, + ) { + Text( + text = if (hero.isMock) "FEATURED · DEMO" else "FEATURED", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = hero.title, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = buildMetadataLine(hero), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (hero.synopsis.isNotBlank()) { + Spacer(Modifier.height(12.dp)) + Text( + text = hero.synopsis, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.9f), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 480.dp), + ) + } + Spacer(Modifier.height(22.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onWatchNow, + modifier = Modifier.focusRequester(watchFocusRequester), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.HeroButtonFocused), + glow = ButtonDefaults.glow( + focusedGlow = Glow( + elevationColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + elevation = 12.dp, + ), + ), + ) { + Text(watchLabel) + } + Button( + onClick = onDetails, + enabled = detailsEnabled, + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(if (detailsEnabled) "Details" else "Demo — unavailable") + } + } + } + } +} + +private fun buildMetadataLine(hero: TvMediaItem): String { + val parts = buildList { + hero.year?.let { add(it.toString()) } + hero.rating?.let { add("★ $it") } + hero.runtime?.let { add(it) } + hero.typeLabel?.let { add(it) } + if (hero.genres.isNotEmpty()) add(hero.genres.take(3).joinToString(" · ")) + } + return parts.joinToString(" • ") +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt new file mode 100644 index 00000000000..0f4644a49f0 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt @@ -0,0 +1,534 @@ +package com.lagradost.cloudstream3.tv.home + +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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.WideButton +import androidx.tv.material3.WideButtonDefaults +import com.lagradost.cloudstream3.tv.components.TvConfirmDialog +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.components.TvOnResume +import com.lagradost.cloudstream3.tv.data.TvContinueWatchingResume +import com.lagradost.cloudstream3.tv.model.TvAvailabilityClassifier +import com.lagradost.cloudstream3.tv.model.TvAvailabilityKind +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvContinueWatchingClassifier +import com.lagradost.cloudstream3.tv.model.TvCwClass +import com.lagradost.cloudstream3.tv.model.TvHeroWatchNow +import com.lagradost.cloudstream3.tv.model.TvHeroWatchResult +import com.lagradost.cloudstream3.tv.model.TvHomeAction +import com.lagradost.cloudstream3.tv.model.TvHomeCatalog +import com.lagradost.cloudstream3.tv.model.TvHomeUiState +import com.lagradost.cloudstream3.tv.model.TvMediaItem +import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest +import com.lagradost.cloudstream3.tv.model.TvRailIds +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Hoisted Home focus memory — survives destination switches in [com.lagradost.cloudstream3.tv.navigation.TvNavigationShell]. + * Indices are coerced against dynamic rail item counts after catalog loads. + */ +class TvHomeFocusState( + val railFocusIndices: SnapshotStateMap = mutableStateMapOf(), +) { + var lastFocusedRailId: String? by mutableStateOf(null) + var initialHeroFocusDone: Boolean by mutableStateOf(false) + + fun indexFor(railId: String): Int = railFocusIndices[railId] ?: 0 + + fun update(railId: String, index: Int) { + railFocusIndices[railId] = index + lastFocusedRailId = railId + } + + /** After CW remove — keep focus on neighbor index (coerced by [pruneTo]). */ + fun preferNeighborAfterRemove(railId: String, removedIndex: Int) { + val next = (removedIndex).coerceAtLeast(0) + railFocusIndices[railId] = next + lastFocusedRailId = railId + } + + /** Drop remembered indices for rails that disappeared or shrank past the index. */ + fun pruneTo(catalog: TvHomeCatalog) { + val alive = catalog.rails.associate { it.id to it.items.size } + val stale = railFocusIndices.keys.filter { it !in alive } + stale.forEach { railFocusIndices.remove(it) } + alive.forEach { (id, size) -> + if (size <= 0) { + railFocusIndices.remove(id) + } else { + val idx = railFocusIndices[id] ?: return@forEach + if (idx > size - 1) railFocusIndices[id] = size - 1 + } + } + if (lastFocusedRailId != null && lastFocusedRailId !in alive) { + lastFocusedRailId = null + } + } +} + +@Composable +fun rememberTvHomeFocusState(): TvHomeFocusState = remember { TvHomeFocusState() } + +private data class CwRemoveCandidate( + val item: TvMediaItem, + val parentId: Int, + val index: Int, +) + +@Composable +fun TvHomeScreen( + focusState: TvHomeFocusState, + modifier: Modifier = Modifier, + onOpenDetails: (TvContentRef) -> Unit = {}, + onPlaybackRequest: (TvPlaybackRequest) -> Unit = {}, + viewModel: TvHomeViewModel = viewModel(), +) { + val uiState by viewModel.state.collectAsState() + var notice by remember { mutableStateOf(null) } + var resolvingId by remember { mutableStateOf(null) } + var removeCandidate by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val resumeResolver = remember { TvContinueWatchingResume() } + + LaunchedEffect(Unit) { viewModel.onAction(TvHomeAction.RefreshContinueWatching) } + TvOnResume { + viewModel.onAction(TvHomeAction.RefreshContinueWatching) + } + + fun openDetailsOrNotice(item: TvMediaItem) { + val ref = TvContentRef.fromMediaItem(item) + if (ref != null) { + notice = null + onOpenDetails(ref) + } else { + notice = if (item.isMock) { + "${TvAvailabilityKind.PlaybackUnavailable.label}: Demo item — details unavailable (never loads fake IDs)." + } else { + "${TvAvailabilityKind.Unavailable.label}: Missing provider URL — cannot open details." + } + } + } + + fun handleSeriesResolve(ref: TvContentRef, hint: com.lagradost.cloudstream3.tv.model.TvResumeHint, trackId: String) { + if (resolvingId != null) return + resolvingId = trackId + notice = "Resuming…" + scope.launch { + val result = try { + withContext(Dispatchers.IO) { + resumeResolver.resolveSeriesResume(ref, hint) + } + } catch (t: Throwable) { + TvContinueWatchingResume.ResolveResult.OpenDetails( + ref, + t.message ?: "Failed to resume — open Details.", + ) + } finally { + resolvingId = null + } + when (result) { + is TvContinueWatchingResume.ResolveResult.Playback -> { + if (result.request.isMock) { + notice = "${TvAvailabilityKind.PlaybackUnavailable.label}: Demo item — never becomes a real TvPlaybackRequest." + } else { + notice = null + onPlaybackRequest(result.request) + } + } + is TvContinueWatchingResume.ResolveResult.OpenDetails -> { + notice = result.message?.let { + "${TvAvailabilityKind.PlaybackUnavailable.label}: $it" + } + onOpenDetails(result.ref.copy(resumeHint = hint)) + } + is TvContinueWatchingResume.ResolveResult.Unavailable -> { + notice = "${TvAvailabilityKind.Unavailable.label}: ${result.message}" + } + } + } + } + + fun onContinueWatchingClick(item: TvMediaItem) { + val classification = TvContinueWatchingClassifier.classifyMediaItem(item) + val availability = TvAvailabilityClassifier.fromCwClassification(classification) + when (classification.clazz) { + TvCwClass.NotSafelyPlayable -> { + notice = "${availability.kind.label}: ${classification.reason}" + } + TvCwClass.DirectPlayable -> { + val request = TvContinueWatchingClassifier.moviePlaybackRequest(item) + if (request == null || request.isMock) { + notice = "${TvAvailabilityKind.PlaybackUnavailable.label}: Demo item — never becomes a real TvPlaybackRequest." + } else { + notice = null + onPlaybackRequest(request) + } + } + TvCwClass.PlayableAfterDetails -> { + val ref = TvContentRef.fromMediaItem(item) + if (ref == null) { + notice = "${TvAvailabilityKind.Unavailable.label}: Missing provider URL — cannot open details." + return + } + val hint = item.resumeHint + val seriesTypes = setOf("TvSeries", "Anime", "Cartoon", "AsianDrama", "OVA") + val isSeriesFamily = item.typeLabel in seriesTypes + if (isSeriesFamily && hint != null && hint.hasExactEpisode) { + handleSeriesResolve(ref, hint, item.id) + } else { + notice = null + onOpenDetails(ref) + } + } + } + } + + fun onHeroWatchNow(hero: TvMediaItem) { + when (val result = TvHeroWatchNow.resolve(hero)) { + is TvHeroWatchResult.Play -> { + if (result.request.isMock) { + notice = "${TvAvailabilityKind.PlaybackUnavailable.label}: Demo — never plays." + } else { + notice = null + onPlaybackRequest(result.request) + } + } + is TvHeroWatchResult.ResolveSeries -> { + handleSeriesResolve(result.ref, result.hint, "hero:${hero.id}") + } + is TvHeroWatchResult.OpenDetails -> { + notice = result.message + onOpenDetails(result.ref) + } + is TvHeroWatchResult.Demo -> { + notice = "${TvAvailabilityKind.PlaybackUnavailable.label}: ${result.message}" + } + is TvHeroWatchResult.Unavailable -> { + val kind = if (result.playbackRelated) { + TvAvailabilityKind.PlaybackUnavailable + } else { + TvAvailabilityKind.Unavailable + } + notice = "${kind.label}: ${result.message}" + } + } + } + + fun onRailItemClick(railId: String, item: TvMediaItem) { + if (railId == TvRailIds.CONTINUE) { + onContinueWatchingClick(item) + } else { + openDetailsOrNotice(item) + } + } + + fun requestRemoveCw(item: TvMediaItem, index: Int) { + if (item.isMock) { + notice = "${TvAvailabilityKind.PlaybackUnavailable.label}: Demo CW cannot be removed from real history." + return + } + val parentId = item.resumeHint?.parentId + if (parentId == null) { + notice = "${TvAvailabilityKind.Unavailable.label}: Missing parentId — cannot remove (no fake remove)." + return + } + removeCandidate = CwRemoveCandidate(item, parentId, index) + } + + Box(modifier = modifier.fillMaxSize()) { + when (val state = uiState) { + is TvHomeUiState.Loading -> TvHomeLoadingPane(Modifier.fillMaxSize()) + is TvHomeUiState.Content -> TvHomeContentPane( + catalog = state.catalog, + focusState = focusState, + notice = notice, + onOpenItem = ::onRailItemClick, + onWatchNow = { onHeroWatchNow(state.catalog.hero) }, + onCwLongClick = { item, index -> requestRemoveCw(item, index) }, + modifier = Modifier.fillMaxSize(), + ) + is TvHomeUiState.Empty -> TvHomeStatusPane( + title = state.availability.label, + body = buildString { + append(state.message) + state.providerName?.let { append("\nProvider: $it") } + append("\n\nReal empty catalog — not demo. Retry after plugins load, or open the explicit demo catalog.") + }, + primaryLabel = "Retry", + onPrimary = { viewModel.onAction(TvHomeAction.Retry) }, + secondaryLabel = "Load demo catalog", + onSecondary = { viewModel.onAction(TvHomeAction.UseMockFallback) }, + modifier = Modifier.fillMaxSize(), + ) + is TvHomeUiState.Error -> TvHomeStatusPane( + title = state.availability.label, + body = state.message + + if (state.canUseMockFallback) { + "\n\nRetry when a homepage provider is ready, or load the demo catalog (explicit fallback — never silent)." + } else { + "" + }, + primaryLabel = "Retry", + onPrimary = { viewModel.onAction(TvHomeAction.Retry) }, + secondaryLabel = if (state.canUseMockFallback) "Load demo catalog" else null, + onSecondary = if (state.canUseMockFallback) { + { viewModel.onAction(TvHomeAction.UseMockFallback) } + } else { + null + }, + modifier = Modifier.fillMaxSize(), + ) + } + + removeCandidate?.let { candidate -> + TvConfirmDialog( + title = "Remove from Continue Watching?", + message = "Remove \"${candidate.item.title}\" from continue watching history? This uses the existing resume remove API.", + confirmLabel = "Remove", + onDismiss = { removeCandidate = null }, + onConfirm = { + val idx = candidate.index + removeCandidate = null + focusState.preferNeighborAfterRemove(TvRailIds.CONTINUE, idx) + viewModel.onAction(TvHomeAction.RemoveContinueWatching(candidate.parentId)) + notice = null + // Restore focus to CW neighbor after list updates. + focusState.lastFocusedRailId = TvRailIds.CONTINUE + }, + ) + } + } +} + +@Composable +private fun TvHomeContentPane( + catalog: TvHomeCatalog, + focusState: TvHomeFocusState, + notice: String?, + onOpenItem: (railId: String, item: TvMediaItem) -> Unit, + onWatchNow: () -> Unit, + onCwLongClick: (TvMediaItem, Int) -> Unit, + modifier: Modifier = Modifier, +) { + val watchFocusRequester = remember { FocusRequester() } + var pendingRestoreRailId by remember { mutableStateOf(null) } + val visibleRails = remember(catalog.rails) { catalog.rails.filter { it.items.isNotEmpty() } } + + LaunchedEffect(catalog) { + focusState.pruneTo(catalog) + // After remove refresh, restore CW focus if that was the last rail. + if (focusState.lastFocusedRailId == TvRailIds.CONTINUE && + visibleRails.any { it.id == TvRailIds.CONTINUE } + ) { + pendingRestoreRailId = TvRailIds.CONTINUE + } + } + + LaunchedEffect(Unit) { + if (!focusState.initialHeroFocusDone) { + runCatching { watchFocusRequester.requestFocus() } + focusState.initialHeroFocusDone = true + } else { + pendingRestoreRailId = focusState.lastFocusedRailId + } + } + + TvOnResume { + val railId = focusState.lastFocusedRailId + if (railId != null) { + pendingRestoreRailId = railId + } + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), + ) { + item(key = "home-state-banner") { + val stateLabel = when { + catalog.usingMockFallback -> "Home · Demo catalog (explicit)" + else -> "Home · Real catalog" + } + Text( + text = buildList { + add(stateLabel) + catalog.providerName?.let { add("Provider: $it") } + if (!catalog.usingMockFallback && catalog.rails.none { it.id == TvRailIds.CONTINUE }) { + add("CW empty") + } + }.joinToString(" · "), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + if (catalog.usingMockFallback || catalog.rails.any { it.isMock } || catalog.hero.isMock) { + item(key = "mock-banner") { + val parts = buildList { + if (catalog.usingMockFallback) add("Full demo catalog (explicit fallback)") + else { + if (catalog.hero.isMock) add("Hero is demo") + catalog.rails.filter { it.isMock }.forEach { add("${it.title} is demo") } + } + catalog.providerName?.let { add("Provider: $it") } + } + Text( + text = parts.joinToString(" · "), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } + if (!notice.isNullOrBlank()) { + item(key = "cw-notice") { + Text( + text = notice, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } + item(key = "hero") { + val heroRef = TvContentRef.fromMediaItem(catalog.hero) + val watchLabel = when (val r = TvHeroWatchNow.resolve(catalog.hero)) { + is TvHeroWatchResult.Play -> "Watch Now" + is TvHeroWatchResult.ResolveSeries -> "Watch Now" + is TvHeroWatchResult.OpenDetails -> "Details" + is TvHeroWatchResult.Demo -> "Demo" + is TvHeroWatchResult.Unavailable -> "Unavailable" + } + TvHeroSection( + hero = catalog.hero, + watchFocusRequester = watchFocusRequester, + detailsEnabled = heroRef != null, + watchLabel = watchLabel, + onWatchNow = onWatchNow, + onDetails = { onOpenItem("hero", catalog.hero) }, + ) + } + itemsIndexed(visibleRails, key = { _, rail -> rail.id }) { _, rail -> + val shouldRestore = pendingRestoreRailId == rail.id + TvContentRail( + rail = rail, + lastFocusedIndex = focusState.indexFor(rail.id), + onFocusedIndexChanged = { index -> focusState.update(rail.id, index) }, + onItemClick = { item -> onOpenItem(rail.id, item) }, + onItemLongClick = if (rail.id == TvRailIds.CONTINUE && !rail.isMock) { + { item -> + val idx = rail.items.indexOfFirst { it.id == item.id } + .takeIf { it >= 0 } ?: focusState.indexFor(rail.id) + onCwLongClick(item, idx) + } + } else { + null + }, + restoreFocus = shouldRestore, + onRestoreConsumed = { + if (pendingRestoreRailId == rail.id) { + pendingRestoreRailId = null + } + }, + ) + } + } +} + +@Composable +private fun TvHomeLoadingPane(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "Loading…", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "Home · ${TvAvailabilityKind.Available.label} pending", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun TvHomeStatusPane( + title: String, + body: String, + primaryLabel: String, + onPrimary: () -> Unit, + secondaryLabel: String?, + onSecondary: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val retryFocus = remember { FocusRequester() } + LaunchedEffect(title) { + runCatching { retryFocus.requestFocus() } + } + Column( + modifier = modifier + .fillMaxSize() + .padding(48.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onPrimary, + modifier = Modifier.focusRequester(retryFocus), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(primaryLabel) + } + if (secondaryLabel != null && onSecondary != null) { + WideButton( + onClick = onSecondary, + scale = WideButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(secondaryLabel) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt new file mode 100644 index 00000000000..49391886638 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt @@ -0,0 +1,148 @@ +package com.lagradost.cloudstream3.tv.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.tv.data.TvContinueWatchingRepository +import com.lagradost.cloudstream3.tv.data.TvHomeRepository +import com.lagradost.cloudstream3.tv.model.TvAvailabilityClassifier +import com.lagradost.cloudstream3.tv.model.TvAvailabilityKind +import com.lagradost.cloudstream3.tv.model.TvHomeAction +import com.lagradost.cloudstream3.tv.model.TvHomeUiState +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Lifecycle-aware Home state holder (MVI / [StateContainer]). + * Loads once from [TvHomeRepository]; Continue Watching refreshed on resume. + * Phase 10: remove CW via existing removeLastWatched; never silent-fail → demo. + */ +class TvHomeViewModel( + private val repository: TvHomeRepository = TvHomeRepository(), + private val continueWatchingRepository: TvContinueWatchingRepository = TvContinueWatchingRepository(), +) : ViewModel(), + StateContainer by DefaultStateContainer(TvHomeUiState.Loading), + ActionHandler { + + private var loadJob: Job? = null + private var cwRefreshJob: Job? = null + + init { + loadCatalog() + } + + override fun onAction(action: TvHomeAction) { + when (action) { + TvHomeAction.Retry -> loadCatalog() + TvHomeAction.UseMockFallback -> { + loadJob?.cancel() + cwRefreshJob?.cancel() + updateState { + TvHomeUiState.Content(repository.mockFallbackCatalog()) + } + } + TvHomeAction.RefreshContinueWatching -> refreshContinueWatching() + is TvHomeAction.RemoveContinueWatching -> removeContinueWatching(action.parentId) + } + } + + private fun loadCatalog() { + loadJob?.cancel() + cwRefreshJob?.cancel() + loadJob = viewModelScope.launch { + updateState { TvHomeUiState.Loading } + val result = try { + withContext(Dispatchers.IO) { repository.loadHome() } + } catch (t: Throwable) { + logError(t) + TvHomeRepository.LoadResult.Failure( + t.message ?: "Unexpected error loading home catalog", + ) + } + updateState { + when (result) { + is TvHomeRepository.LoadResult.Success -> + TvHomeUiState.Content(result.catalog) + + is TvHomeRepository.LoadResult.Empty -> + TvHomeUiState.Empty( + providerName = result.providerName, + availability = TvAvailabilityKind.Unavailable, + ) + + is TvHomeRepository.LoadResult.Failure -> { + val status = TvAvailabilityClassifier.fromHomeFailure(result.message) + TvHomeUiState.Error( + message = result.message, + canUseMockFallback = true, + availability = status.kind, + ) + } + } + } + } + } + + /** Read-only CW rail swap — skips when showing mock fallback or non-Content. */ + private fun refreshContinueWatching() { + val current = state.value + if (current !is TvHomeUiState.Content) return + if (current.catalog.usingMockFallback) return + cwRefreshJob?.cancel() + cwRefreshJob = viewModelScope.launch { + val refreshed = try { + withContext(Dispatchers.IO) { + repository.withRefreshedContinueWatching(current.catalog) + } + } catch (t: Throwable) { + logError(t) + // CW refresh failure must not swap catalog into demo. + return@launch + } + val latest = state.value + if (latest is TvHomeUiState.Content && !latest.catalog.usingMockFallback) { + updateState { TvHomeUiState.Content(refreshed) } + } + } + } + + /** + * Phase 10 — existing [DataStoreHelper.removeLastWatched] via repository. + * Then re-read CW rail only (no homepage network, no demo swap). + */ + private fun removeContinueWatching(parentId: Int) { + val current = state.value + if (current !is TvHomeUiState.Content) return + if (current.catalog.usingMockFallback) return + cwRefreshJob?.cancel() + cwRefreshJob = viewModelScope.launch { + try { + withContext(Dispatchers.IO) { + continueWatchingRepository.removeContinueWatching(parentId) + } + } catch (t: Throwable) { + logError(t) + return@launch + } + val latest = state.value + if (latest !is TvHomeUiState.Content || latest.catalog.usingMockFallback) return@launch + val refreshed = try { + withContext(Dispatchers.IO) { + repository.withRefreshedContinueWatching(latest.catalog) + } + } catch (t: Throwable) { + logError(t) + return@launch + } + val after = state.value + if (after is TvHomeUiState.Content && !after.catalog.usingMockFallback) { + updateState { TvHomeUiState.Content(refreshed) } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvAvailabilityModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvAvailabilityModels.kt new file mode 100644 index 00000000000..54d0cfc1814 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvAvailabilityModels.kt @@ -0,0 +1,174 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Phase 10 — richer stale / availability labels. + * + * Only distinctions the backend / existing TV adapters can establish. + * Never invent provider presence, playback readiness, or silent content swaps. + */ +enum class TvAvailabilityKind(val label: String) { + /** Content identity present and path is known-good for this surface. */ + Available("Available"), + + /** Insufficient identity or explicitly not safely actionable. */ + Unavailable("Unavailable"), + + /** No / missing homepage or search provider (plugins not ready, api missing). */ + ProviderMissing("Provider Missing"), + + /** Network / APIRepository / getMainPage / load failure. */ + LoadFailed("Load Failed"), + + /** Identity OK but Compose TV cannot play (Live/Torrent/comingSoon/demo/no episode data). */ + PlaybackUnavailable("Playback Unavailable"), +} + +data class TvAvailabilityStatus( + val kind: TvAvailabilityKind, + val message: String, +) { + val title: String get() = kind.label +} + +/** + * Map failure / empty messages already produced by TV repositories to a kind. + * Heuristics stay conservative — prefer LoadFailed when ambiguous. + */ +object TvAvailabilityClassifier { + + fun fromHomeFailure(message: String): TvAvailabilityStatus { + val m = message.lowercase() + return when { + m.contains("no homepage provider") || + m.contains("does not exist") || + m.contains("no search providers") || + m.contains("has no homepage") || + (m.contains("plugins") && m.contains("provider")) -> + TvAvailabilityStatus(TvAvailabilityKind.ProviderMissing, message) + + else -> TvAvailabilityStatus(TvAvailabilityKind.LoadFailed, message) + } + } + + fun fromSearchFailure(message: String): TvAvailabilityStatus { + val m = message.lowercase() + return when { + m.contains("no search providers") || + m.contains("provider does not exist") || + m.contains("plugins") -> + TvAvailabilityStatus(TvAvailabilityKind.ProviderMissing, message) + + else -> TvAvailabilityStatus(TvAvailabilityKind.LoadFailed, message) + } + } + + fun fromDetailsFailure(message: String): TvAvailabilityStatus { + val m = message.lowercase() + return when { + m.contains("does not exist") || + (m.contains("provider") && (m.contains("missing") || m.contains("not"))) -> + TvAvailabilityStatus(TvAvailabilityKind.ProviderMissing, message) + + else -> TvAvailabilityStatus(TvAvailabilityKind.LoadFailed, message) + } + } + + fun fromWatchlistFailure(message: String): TvAvailabilityStatus = + TvAvailabilityStatus(TvAvailabilityKind.LoadFailed, message) + + fun fromCwClassification(classification: TvCwClassification): TvAvailabilityStatus = + when (classification.clazz) { + TvCwClass.DirectPlayable, + TvCwClass.PlayableAfterDetails, + -> TvAvailabilityStatus(TvAvailabilityKind.Available, classification.reason) + + TvCwClass.NotSafelyPlayable -> { + val m = classification.reason.lowercase() + val kind = when { + m.contains("live") || m.contains("torrent") || m.contains("demo") || + m.contains("not supported") -> + TvAvailabilityKind.PlaybackUnavailable + else -> TvAvailabilityKind.Unavailable + } + TvAvailabilityStatus(kind, classification.reason) + } + } +} + +/** + * Phase 10 Hero Watch Now — same identity rules as CW classifier. + * + * - Movie + identity → [Play] TvPlaybackRequest → bridge + * - Series/Anime + Phase 9-safe exact episode hint → [ResolveSeries] (same resume path) + * - Else Series/Anime / other → [OpenDetails] + * - Mock → [Demo] never plays + */ +sealed interface TvHeroWatchResult { + data class Play(val request: TvPlaybackRequest) : TvHeroWatchResult + + /** Series/Anime with exact S/E or episodeId — resolve via TvContinueWatchingResume. */ + data class ResolveSeries( + val ref: TvContentRef, + val hint: TvResumeHint, + ) : TvHeroWatchResult + + data class OpenDetails(val ref: TvContentRef, val message: String? = null) : TvHeroWatchResult + data class Demo(val message: String) : TvHeroWatchResult + data class Unavailable(val message: String, val playbackRelated: Boolean = false) : TvHeroWatchResult +} + +object TvHeroWatchNow { + + private val seriesOrAnimeTypes = setOf( + "TvSeries", + "Anime", + "Cartoon", + "AsianDrama", + "OVA", + ) + + fun resolve(item: TvMediaItem): TvHeroWatchResult { + if (item.isMock) { + return TvHeroWatchResult.Demo( + "Demo item — Watch Now unavailable (never becomes a real TvPlaybackRequest).", + ) + } + val classification = TvContinueWatchingClassifier.classifyMediaItem(item) + when (classification.clazz) { + TvCwClass.NotSafelyPlayable -> { + val status = TvAvailabilityClassifier.fromCwClassification(classification) + return TvHeroWatchResult.Unavailable( + classification.reason, + playbackRelated = status.kind == TvAvailabilityKind.PlaybackUnavailable, + ) + } + TvCwClass.DirectPlayable -> { + val request = TvContinueWatchingClassifier.moviePlaybackRequest(item) + ?: return TvHeroWatchResult.Unavailable( + "Movie identity incomplete — cannot Watch Now.", + playbackRelated = true, + ) + return TvHeroWatchResult.Play(request) + } + TvCwClass.PlayableAfterDetails -> { + val ref = TvContentRef.fromMediaItem(item) + ?: return TvHeroWatchResult.Unavailable( + "Missing provider URL — cannot open Details.", + ) + val hint = item.resumeHint + val isSeriesFamily = item.typeLabel in seriesOrAnimeTypes + if (isSeriesFamily && hint != null && hint.hasExactEpisode) { + return TvHeroWatchResult.ResolveSeries(ref.copy(resumeHint = hint), hint) + } + return TvHeroWatchResult.OpenDetails( + ref, + if (isSeriesFamily) { + "Series/Anime — open Details to pick an episode (no deterministic hero target)." + } else { + "Open Details — do not guess playback variant from hero alone." + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvContinueWatchingPlayability.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvContinueWatchingPlayability.kt new file mode 100644 index 00000000000..db8159c5605 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvContinueWatchingPlayability.kt @@ -0,0 +1,161 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Phase 9 Continue Watching playability — classified from real Phase 8 fields only. + * + * Exact resume fields available on [TvContinueWatchingItem]: + * id, title, url, apiName, posterUrl, typeLabel, progressFraction?, + * episode?, season?, parentId?, episodeId?, updateTimeMs + * + * Missing for series direct play: Episode.data (playable payload) — never invent it. + * Progress / PosDur is display-only; the existing player owns seek. + */ +enum class TvCwClass { + /** A — Movie with url + apiName + title; build TvPlaybackRequest → bridge. */ + DirectPlayable, + + /** B — Identity enough for Details; series may resolve to fromEpisode after load. */ + PlayableAfterDetails, + + /** C — Insufficient or unsupported; clear unavailable, never play. */ + NotSafelyPlayable, +} + +data class TvCwClassification( + val clazz: TvCwClass, + /** Human-readable reason for C, or brief note for A/B. */ + val reason: String, +) { + val isDirect: Boolean get() = clazz == TvCwClass.DirectPlayable + val isDetails: Boolean get() = clazz == TvCwClass.PlayableAfterDetails + val isUnavailable: Boolean get() = clazz == TvCwClass.NotSafelyPlayable +} + +/** + * Compact resume hints carried on CW [TvMediaItem] / [TvContentRef] for Details restore + * and series resolve — never invent episode URLs. + */ +data class TvResumeHint( + val season: Int? = null, + val episode: Int? = null, + val episodeId: Int? = null, + val parentId: Int? = null, + val typeLabel: String? = null, +) { + val hasExactEpisode: Boolean + get() = episode != null || episodeId != null +} + +object TvContinueWatchingClassifier { + + /** Types that Compose TV can play as Movie via existing bridge (variantLabel = Movie). */ + private val directMovieTypes = setOf("Movie") + + /** Series/anime families that use episode selector + fromEpisode after Details load. */ + private val seriesOrAnimeTypes = setOf( + "TvSeries", + "Anime", + "Cartoon", + "AsianDrama", + "OVA", + ) + + private val blockedTypes = setOf("Live", "Torrent") + + fun classify(item: TvContinueWatchingItem): TvCwClassification { + if (item.url.isBlank() || item.apiName.isBlank() || item.title.isBlank()) { + return TvCwClassification( + TvCwClass.NotSafelyPlayable, + "Missing title, provider, or URL — cannot resume safely.", + ) + } + val type = item.typeLabel + return when { + type != null && type in blockedTypes -> TvCwClassification( + TvCwClass.NotSafelyPlayable, + "$type playback is not supported in Compose TV yet.", + ) + type != null && type in directMovieTypes -> TvCwClassification( + TvCwClass.DirectPlayable, + "Movie — Resume → TvPlaybackRequest → TvPlaybackBridge (player owns seek).", + ) + type != null && type in seriesOrAnimeTypes -> TvCwClassification( + TvCwClass.PlayableAfterDetails, + if (item.season != null && item.episode != null) { + "Series/Anime S${item.season} E${item.episode} — resolve Episode.data via Details load, then fromEpisode." + } else if (item.episodeId != null) { + "Series/Anime episodeId=${item.episodeId} — resolve via Details load, then fromEpisode." + } else { + "Series/Anime — open Details (no exact season+episode to resume)." + }, + ) + // AnimeMovie / Documentary / Video / etc.: identity enough for Details — do not guess MovieLoadResponse. + type != null -> TvCwClassification( + TvCwClass.PlayableAfterDetails, + "Type $type — open shared Details (do not guess playback variant from CW alone).", + ) + // typeLabel missing but identity present → Details only + else -> TvCwClassification( + TvCwClass.PlayableAfterDetails, + "Unknown type — open Details; never invent playback variant.", + ) + } + } + + fun classifyMediaItem(item: TvMediaItem): TvCwClassification { + if (item.isMock) { + return TvCwClassification( + TvCwClass.NotSafelyPlayable, + "Demo item — never becomes a real TvPlaybackRequest.", + ) + } + val url = item.url?.takeIf { it.isNotBlank() } + val api = item.apiName?.takeIf { it.isNotBlank() } + if (url == null || api == null || item.title.isBlank()) { + return TvCwClassification( + TvCwClass.NotSafelyPlayable, + "Missing title, provider, or URL — cannot resume safely.", + ) + } + // Rebuild a minimal CW item for the same classifier rules. + return classify( + TvContinueWatchingItem( + id = item.id, + title = item.title, + url = url, + apiName = api, + posterUrl = item.posterUrl, + typeLabel = item.typeLabel, + progressFraction = item.progressFraction, + episode = item.resumeHint?.episode, + season = item.resumeHint?.season, + parentId = item.resumeHint?.parentId, + episodeId = item.resumeHint?.episodeId, + ), + ) + } + + fun moviePlaybackRequest(item: TvMediaItem): TvPlaybackRequest? { + if (item.isMock) return null + val classification = classifyMediaItem(item) + if (!classification.isDirect) return null + val url = item.url?.takeIf { it.isNotBlank() } ?: return null + val api = item.apiName?.takeIf { it.isNotBlank() } ?: return null + return TvPlaybackRequest( + url = url, + apiName = api, + title = item.title, + variantLabel = "Movie", + comingSoon = false, + isMock = false, + ) + } + + fun resumeHintOf(item: TvContinueWatchingItem): TvResumeHint = TvResumeHint( + season = item.season, + episode = item.episode, + episodeId = item.episodeId, + parentId = item.parentId, + typeLabel = item.typeLabel, + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt new file mode 100644 index 00000000000..767d75850f5 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt @@ -0,0 +1,138 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Compact immutable content identity for Details navigation. + * Mirrors ResultFragment bundles: url + apiName (+ display title). + * Never put mutable SearchResponse / LoadResponse in Compose nav state. + */ +data class TvContentRef( + val url: String, + val apiName: String, + val title: String = "", + /** + * Phase 9 optional CW restore — season/episode/episodeId from read-only resume. + * Details applies when present; never invents missing episode data. + */ + val resumeHint: TvResumeHint? = null, +) { + init { + require(url.isNotBlank()) { "TvContentRef.url must be non-blank" } + require(apiName.isNotBlank()) { "TvContentRef.apiName must be non-blank" } + } + + companion object { + /** + * Build a loadable ref from a Home card. Returns null for mock/demo items + * or when url/apiName are missing — callers must never invent fake IDs. + */ + fun fromMediaItem(item: TvMediaItem): TvContentRef? { + if (item.isMock) return null + val url = item.url?.takeIf { it.isNotBlank() } ?: return null + val apiName = item.apiName?.takeIf { it.isNotBlank() } ?: return null + return TvContentRef( + url = url, + apiName = apiName, + title = item.title, + resumeHint = item.resumeHint, + ) + } + } +} + +/** Successful details payload inside [TvDetailsUiState.Content]. Fields only from LoadResponse. */ +data class TvDetailsContent( + val title: String, + val posterUrl: String?, + val backdropUrl: String?, + val year: Int?, + val rating: String?, + val runtime: String?, + val genres: List, + val synopsis: String, + val typeLabel: String?, + val contentRating: String?, + val showStatus: String?, + val comingSoon: Boolean, + val episodeCount: Int?, + val actors: List, + val apiName: String, + val url: String, + /** Concrete LoadResponse kind: Movie, TvSeries, Anime, LiveStream, Torrent, Other. */ + val variantLabel: String, + val posterHeaders: Map?, + /** + * Season/episode tree for TvSeries / Anime (empty for Movie / Live / Torrent). + * Anime: one [TvDubGroup] per DubStatus with episodes; Series: single None group. + */ + val dubGroups: List = emptyList(), + /** Precomputed defaults — see [TvEpisodeDefaults]. */ + val defaultDubStatusId: Int? = null, + val defaultSeasonIndex: Int? = null, + val defaultEpisodeId: Int? = null, +) { + val hasEpisodeSelector: Boolean + get() = variantLabel == "TvSeries" || variantLabel == "Anime" + + fun seasonsForDub(dubStatusId: Int?): List { + if (dubGroups.isEmpty()) return emptyList() + val match = dubGroups.firstOrNull { it.dubStatusId == dubStatusId } + return match?.seasons ?: dubGroups.first().seasons + } + + fun episodeById(episodeId: Int?): TvEpisode? { + if (episodeId == null) return null + return dubGroups.asSequence() + .flatMap { it.seasons.asSequence() } + .flatMap { it.episodes.asSequence() } + .firstOrNull { it.id == episodeId } + } +} + +sealed interface TvDetailsUiState { + data class Loading( + val titleHint: String? = null, + ) : TvDetailsUiState + + data class Content( + val details: TvDetailsContent, + val selectedDubStatusId: Int? = details.defaultDubStatusId, + val selectedSeasonIndex: Int? = details.defaultSeasonIndex, + val selectedEpisodeId: Int? = details.defaultEpisodeId, + ) : TvDetailsUiState { + val visibleSeasons: List + get() = details.seasonsForDub(selectedDubStatusId) + + val selectedSeason: TvSeason? + get() = visibleSeasons.firstOrNull { it.seasonIndex == selectedSeasonIndex } + ?: visibleSeasons.firstOrNull() + + val visibleEpisodes: List + get() = selectedSeason?.episodes.orEmpty() + + val selectedEpisode: TvEpisode? + get() = visibleEpisodes.firstOrNull { it.id == selectedEpisodeId } + ?: details.episodeById(selectedEpisodeId) + ?: visibleEpisodes.firstOrNull { it.isPlayable } + ?: visibleEpisodes.firstOrNull() + + val showDubSelector: Boolean + get() = details.variantLabel == "Anime" && details.dubGroups.size > 1 + } + + data class Error( + val message: String, + val titleHint: String? = null, + ) : TvDetailsUiState +} + +sealed interface TvDetailsAction { + data object Retry : TvDetailsAction + data object Back : TvDetailsAction + /** Movies — Activity-level playback (Phase 5 path). */ + data object WatchNow : TvDetailsAction + /** Series / Anime — play [TvDetailsUiState.Content.selectedEpisode]. */ + data object PlaySelectedEpisode : TvDetailsAction + data class SelectDubStatus(val dubStatusId: Int) : TvDetailsAction + data class SelectSeason(val seasonIndex: Int) : TvDetailsAction + data class SelectEpisode(val episodeId: Int) : TvDetailsAction +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvEpisodeModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvEpisodeModels.kt new file mode 100644 index 00000000000..ba7785d4f04 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvEpisodeModels.kt @@ -0,0 +1,117 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Immutable TV-side episode / season models mapped from real [com.lagradost.cloudstream3.Episode] + * and [com.lagradost.cloudstream3.SeasonData] fields only — never invent domain shape. + * + * Domain facts (MainAPI.kt): + * - Episode: data, name?, season?, episode?, posterUrl?, score?, description?, date?, runTime? + * - season / episode numbers are Int? only (no string/fractional episode IDs in LoadResponse) + * - SeasonData: season (Int), name?, displaySeason? + * - TvSeriesLoadResponse.episodes: List<Episode> + * - AnimeLoadResponse.episodes: MutableMap<DubStatus, List<Episode>> + * - season == null → grouped as seasonIndex 0 ("No Season"); season == 0 also "No Season" / specials bucket + */ + +/** One DubStatus bucket for anime (or a single None group for TV series). */ +data class TvDubGroup( + /** [com.lagradost.cloudstream3.DubStatus.id] (-1 None, 0 Subbed, 1 Dubbed). */ + val dubStatusId: Int, + val label: String, + val seasons: List, +) + +data class TvSeason( + /** + * Grouping key = Episode.season ?: 0 (same as ResultViewModel2 EpisodeIndexer.season). + * 0 = missing / explicit zero → UI label "No Season" (specials bucket). + */ + val seasonIndex: Int, + /** SeasonData.displaySeason when present; else Episode.season (may be null). */ + val displaySeason: Int?, + /** SeasonData.name when present. */ + val name: String?, + /** Precomputed 10ft label ("Season 1", "No Season", or custom name). */ + val label: String, + val episodes: List, +) + +data class TvEpisode( + /** + * Stable id matching ResultViewModel2 formulas so existing player / link cache keys align. + * TvSeries: mainId + (season?.times(100_000) ?: 0) + episodeNumber + 1 + * Anime: mainId + episodeNumber + dubStatusId * 1_000_000 + (season?.times(10_000) ?: 0) + */ + val id: Int, + /** Index within the source Episode list for that dub (ResultViewModel2 index). */ + val index: Int, + /** Episode.episode ?: (index + 1) — domain is Int? only. */ + val episodeNumber: Int, + val name: String?, + val description: String?, + val posterUrl: String?, + /** Raw Episode.season (null when missing). */ + val seasonIndex: Int?, + /** Display season for ResultEpisode.season (SeasonData.displaySeason ?: Episode.season). */ + val displaySeason: Int?, + /** Episode.data — required payload for APIRepository.loadLinks / RepoLinkGenerator. */ + val data: String, + val airDate: Long?, + val runTime: Int?, + val scoreLabel: String?, + val dubStatusId: Int, + val totalEpisodeIndex: Int?, + /** True when [data] is non-blank — only playable episodes launch GeneratorPlayer. */ + val isPlayable: Boolean, +) { + val titleLine: String + get() { + val ep = "E$episodeNumber" + val n = name?.takeIf { it.isNotBlank() } + return if (n != null) "$ep · $n" else ep + } +} + +/** + * Default selection rule (Phase 6 — deterministic, NO resume / DataStore writes): + * 1. Dub: Subbed if it has episodes, else Dubbed, else None, else first non-empty group. + * 2. Season: lowest seasonIndex among seasons with seasonIndex != 0; if none, seasonIndex 0. + * 3. Episode: first episode in that season with isPlayable; else first episode in that season. + */ +object TvEpisodeDefaults { + fun pickDubStatusId(groups: List): Int? { + if (groups.isEmpty()) return null + val sub = groups.firstOrNull { it.dubStatusId == 0 && it.seasons.any { s -> s.episodes.isNotEmpty() } } + if (sub != null) return sub.dubStatusId + val dub = groups.firstOrNull { it.dubStatusId == 1 && it.seasons.any { s -> s.episodes.isNotEmpty() } } + if (dub != null) return dub.dubStatusId + val none = groups.firstOrNull { it.dubStatusId == -1 && it.seasons.any { s -> s.episodes.isNotEmpty() } } + if (none != null) return none.dubStatusId + return groups.firstOrNull { it.seasons.any { s -> s.episodes.isNotEmpty() } }?.dubStatusId + ?: groups.firstOrNull()?.dubStatusId + } + + fun pickSeasonIndex(seasons: List): Int? { + if (seasons.isEmpty()) return null + val regular = seasons.filter { it.seasonIndex != 0 && it.episodes.isNotEmpty() } + .minByOrNull { it.seasonIndex } + if (regular != null) return regular.seasonIndex + return seasons.firstOrNull { it.episodes.isNotEmpty() }?.seasonIndex + ?: seasons.firstOrNull()?.seasonIndex + } + + fun pickEpisodeId(season: TvSeason?): Int? { + if (season == null || season.episodes.isEmpty()) return null + return season.episodes.firstOrNull { it.isPlayable }?.id + ?: season.episodes.firstOrNull()?.id + } + + fun defaultsFor(groups: List): Triple { + val dubId = pickDubStatusId(groups) + val seasons = groups.firstOrNull { it.dubStatusId == dubId }?.seasons.orEmpty() + val seasonIndex = pickSeasonIndex(seasons) + val season = seasons.firstOrNull { it.seasonIndex == seasonIndex } + val episodeId = pickEpisodeId(season) + return Triple(dubId, seasonIndex, episodeId) + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt new file mode 100644 index 00000000000..3a3c4acf125 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt @@ -0,0 +1,101 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Immutable TV presentation models for Compose Home (Phase 3). + * Mapped from domain [com.lagradost.cloudstream3.SearchResponse] — UI must not hold mutable DTOs. + */ + +data class TvMediaItem( + val id: String, + val title: String, + val subtitle: String = "", + /** Null-safe; Coil shows placeholder when null/blank. */ + val posterUrl: String? = null, + /** SearchResponse has no backdrop — use poster or mock. */ + val backdropUrl: String? = posterUrl, + val year: Int? = null, + val rating: String? = null, + val runtime: String? = null, + val genres: List = emptyList(), + val synopsis: String = "", + val progressFraction: Float? = null, + val apiName: String? = null, + val url: String? = null, + val typeLabel: String? = null, + val posterHeaders: Map? = null, + val isMock: Boolean = false, + /** + * Phase 9: present only on Continue Watching cards mapped from [TvContinueWatchingItem]. + * Null for Home/Search/Watchlist cards — never invent resume metadata. + */ + val resumeHint: TvResumeHint? = null, + /** + * Phase 10: optional availability badge (CW stale / demo). Null = treat as Available. + */ + val availabilityKind: TvAvailabilityKind? = null, +) + +data class TvContentRail( + val id: String, + val title: String, + val items: List, + /** True when this rail is demo/mock data (never silent). */ + val isMock: Boolean = false, +) + +/** Successful catalog payload inside [TvHomeUiState.Content]. */ +data class TvHomeCatalog( + val hero: TvMediaItem, + val rails: List, + val providerName: String? = null, + /** Entire catalog is demo fallback chosen by the user. */ + val usingMockFallback: Boolean = false, +) + +sealed interface TvHomeUiState { + data object Loading : TvHomeUiState + + data class Content( + val catalog: TvHomeCatalog, + ) : TvHomeUiState + + data class Empty( + val providerName: String?, + val message: String = "No catalog items from the current provider.", + /** Explicit empty — never silent-fail into demo. */ + val availability: TvAvailabilityKind = TvAvailabilityKind.Unavailable, + ) : TvHomeUiState + + data class Error( + val message: String, + val canUseMockFallback: Boolean = true, + val availability: TvAvailabilityKind = TvAvailabilityKind.LoadFailed, + ) : TvHomeUiState +} + +sealed interface TvHomeAction { + data object Retry : TvHomeAction + data object UseMockFallback : TvHomeAction + /** Re-read Continue Watching only (enter/resume) — no homepage network. */ + data object RefreshContinueWatching : TvHomeAction + /** + * Phase 10 — remove one CW row via existing DataStoreHelper.removeLastWatched. + * No new keys; parentId from resume hint only. + */ + data class RemoveContinueWatching(val parentId: Int) : TvHomeAction +} + +enum class TvDestination(val label: String) { + Home("Home"), + Search("Search"), + Watchlist("Watchlist"), + Settings("Settings"), +} + +/** Fixed Phase 2/3 rail ids. */ +object TvRailIds { + const val CONTINUE = "continue" + const val TRENDING = "trending" + const val MOVIES = "movies" + const val ANIME = "anime" +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockCatalog.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockCatalog.kt new file mode 100644 index 00000000000..da619f52aa1 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockCatalog.kt @@ -0,0 +1,109 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Explicit demo catalog for Phase 3 mock fallback / Continue Watching. + * Never presented as a silent API success. + */ +object TvMockCatalog { + private const val P = "https://picsum.photos/seed" + + val hero: TvMediaItem = TvMediaItem( + id = "hero-nebula", + title = "Nebula Drift", + subtitle = "Original · Sci-Fi · Demo", + posterUrl = "$P/nebula-poster/400/600", + backdropUrl = "$P/nebula-backdrop/1280/720", + year = 2026, + rating = "8.4", + runtime = "2h 14m", + genres = listOf("Sci-Fi", "Adventure", "Drama"), + synopsis = "A salvage crew chasing a dying star discovers a signal that rewrites " + + "everything they know about home. Explicit Compose TV demo hero.", + isMock = true, + availabilityKind = TvAvailabilityKind.PlaybackUnavailable, + ) + + val continueWatching: TvContentRail = TvContentRail( + id = TvRailIds.CONTINUE, + title = "Continue Watching", + isMock = true, + items = listOf( + item("cw1", "Harbor Lights", "S2 E4 · 42m left", "harbor", progress = 0.62f, year = 2025), + item("cw2", "Glass Kingdom", "1h 05m left", "glass", progress = 0.35f, year = 2024), + item("cw3", "Midnight Courier", "S1 E7 · 18m left", "courier", progress = 0.81f, year = 2026), + item("cw4", "Iron Orchard", "E3 · 51m left", "orchard", progress = 0.22f, year = 2023), + item("cw5", "Silent Cascade", "S3 E1 · 1h left", "cascade", progress = 0.08f, year = 2025), + ), + ) + + val trending: TvContentRail = TvContentRail( + id = TvRailIds.TRENDING, + title = "Trending", + isMock = true, + items = listOf( + item("tr1", "Crimson Atlas", "Thriller", "crimson", year = 2026, rating = "8.1"), + item("tr2", "Polar Echo", "Mystery", "polar", year = 2025, rating = "7.9"), + item("tr3", "Velvet Circuit", "Action", "velvet", year = 2026, rating = "8.6"), + item("tr4", "Ashen Choir", "Horror", "ashen", year = 2024, rating = "7.4"), + item("tr5", "Lumen Protocol", "Sci-Fi", "lumen", year = 2026, rating = "8.9"), + item("tr6", "Desert Frequency", "Drama", "desert", year = 2025, rating = "7.7"), + ), + ) + + val movies: TvContentRail = TvContentRail( + id = TvRailIds.MOVIES, + title = "Movies", + isMock = true, + items = listOf( + item("mv1", "Last Ember", "Feature", "ember", year = 2022, runtime = "1h 58m"), + item("mv2", "Paper Storm", "Feature", "paper", year = 2023, runtime = "2h 05m"), + item("mv3", "Copper Sky", "Feature", "copper", year = 2021, runtime = "1h 44m"), + item("mv4", "Night Archive", "Feature", "archive", year = 2024, runtime = "2h 18m"), + item("mv5", "River of Static", "Feature", "river", year = 2025, runtime = "1h 51m"), + ), + ) + + val anime: TvContentRail = TvContentRail( + id = TvRailIds.ANIME, + title = "Anime", + isMock = true, + items = listOf( + item("an1", "Starforged Academy", "TV · 24 ep", "starforge", year = 2026), + item("an2", "Kitsune Circuit", "TV · 12 ep", "kitsune", year = 2025), + item("an3", "Orbital Sakura", "Movie", "sakura", year = 2024), + item("an4", "Blade of Mist", "TV · 13 ep", "blade", year = 2023), + item("an5", "Chrono Harbor", "OVA", "chrono", year = 2026), + item("an6", "Neon Shrine", "TV · 26 ep", "neon", year = 2025), + ), + ) + + val fullFallback: TvHomeCatalog = TvHomeCatalog( + hero = hero, + rails = listOf(continueWatching, trending, movies, anime), + providerName = null, + usingMockFallback = true, + ) + + private fun item( + id: String, + title: String, + subtitle: String, + seed: String, + year: Int? = null, + rating: String? = null, + runtime: String? = null, + progress: Float? = null, + ) = TvMediaItem( + id = id, + title = title, + subtitle = subtitle, + posterUrl = "$P/$seed-poster/400/600", + backdropUrl = "$P/$seed-backdrop/800/450", + year = year, + rating = rating, + runtime = runtime, + progressFraction = progress, + isMock = true, + availabilityKind = TvAvailabilityKind.PlaybackUnavailable, + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt new file mode 100644 index 00000000000..2894f5eb8cd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt @@ -0,0 +1,104 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Immutable playback request for Activity-level launch. + * Min fields only — never put [com.lagradost.cloudstream3.LoadResponse], Activity, + * or mutable UI state here. + * + * [variantLabel] mirrors [TvDetailsContent.variantLabel] + * (Movie / TvSeries / Anime / LiveStream / Torrent / Other). + * + * Episode path (Phase 6): optional episode* fields carry the selected [TvEpisode] + * so [com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge] can build ResultEpisode + * the same way ResultViewModel2 does for movies — but with episode data. + */ +data class TvPlaybackRequest( + val url: String, + val apiName: String, + val title: String, + val variantLabel: String, + val comingSoon: Boolean = false, + /** True for Compose demo / mock catalog — bridge must never launch real playback. */ + val isMock: Boolean = false, + // --- Episode path (null for movies) --- + val episodeData: String? = null, + val episodeNumber: Int? = null, + val seasonIndex: Int? = null, + val displaySeason: Int? = null, + val episodeName: String? = null, + val episodePoster: String? = null, + val episodeDescription: String? = null, + val episodeId: Int? = null, + val episodeIndex: Int? = null, + val parentId: Int? = null, + val totalEpisodeIndex: Int? = null, + val airDate: Long? = null, + val runTime: Int? = null, + val dubStatusId: Int? = null, +) { + init { + require(url.isNotBlank()) { "TvPlaybackRequest.url must be non-blank" } + require(apiName.isNotBlank()) { "TvPlaybackRequest.apiName must be non-blank" } + } + + val isMovie: Boolean get() = variantLabel == "Movie" + + val isEpisodePlayback: Boolean + get() = !episodeData.isNullOrBlank() && episodeId != null && episodeNumber != null + + companion object { + fun fromDetails(details: TvDetailsContent, isMock: Boolean = false): TvPlaybackRequest = + TvPlaybackRequest( + url = details.url, + apiName = details.apiName, + title = details.title, + variantLabel = details.variantLabel, + comingSoon = details.comingSoon, + isMock = isMock, + ) + + fun fromEpisode( + details: TvDetailsContent, + episode: TvEpisode, + isMock: Boolean = false, + ): TvPlaybackRequest = + TvPlaybackRequest( + url = details.url, + apiName = details.apiName, + title = details.title, + variantLabel = details.variantLabel, + comingSoon = details.comingSoon, + isMock = isMock, + episodeData = episode.data, + episodeNumber = episode.episodeNumber, + seasonIndex = episode.seasonIndex, + displaySeason = episode.displaySeason, + episodeName = episode.name, + episodePoster = episode.posterUrl, + episodeDescription = episode.description, + episodeId = episode.id, + episodeIndex = episode.index, + parentId = null, // Bridge fills from LoadResponse.getId() + totalEpisodeIndex = episode.totalEpisodeIndex, + airDate = episode.airDate, + runTime = episode.runTime, + dubStatusId = episode.dubStatusId, + ) + } +} + +/** Why primary playback CTA is unavailable for this content. */ +fun TvDetailsContent.watchNowDisabledReason(): String? = when { + comingSoon -> "Coming soon — not released yet" + variantLabel == "Movie" -> null + variantLabel == "TvSeries" || variantLabel == "Anime" -> { + val anyPlayable = dubGroups.any { g -> g.seasons.any { s -> s.episodes.any { it.isPlayable } } } + if (anyPlayable) null else "No playable episodes found" + } + variantLabel == "LiveStream" -> "Live playback is not supported in Compose TV yet" + variantLabel == "Torrent" -> "Torrent playback is not supported in Compose TV yet" + else -> "Playback for $variantLabel is not supported" +} + +fun TvDetailsContent.isSeriesOrAnime(): Boolean = + variantLabel == "TvSeries" || variantLabel == "Anime" diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSearchModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSearchModels.kt new file mode 100644 index 00000000000..c6532581346 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSearchModels.kt @@ -0,0 +1,76 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Immutable TV search presentation models (Phase 7). + * Mapped from domain [com.lagradost.cloudstream3.SearchResponse] — UI must not hold mutable DTOs. + * Navigation identity is always [TvContentRef] — same Details path as Home. + */ +data class TvSearchResult( + val id: String, + val title: String, + val subtitle: String = "", + val posterUrl: String? = null, + val year: Int? = null, + val rating: String? = null, + val typeLabel: String? = null, + val providerName: String, + val posterHeaders: Map? = null, + /** Same compact identity Home uses for [com.lagradost.cloudstream3.tv.details.TvDetailsScreen]. */ + val contentRef: TvContentRef, +) { + fun toMediaItem(): TvMediaItem = TvMediaItem( + id = id, + title = title, + subtitle = subtitle, + posterUrl = posterUrl, + backdropUrl = posterUrl, + year = year, + rating = rating, + typeLabel = typeLabel, + apiName = contentRef.apiName, + url = contentRef.url, + posterHeaders = posterHeaders, + isMock = false, + ) +} + +/** Successful search payload inside [TvSearchUiState.Content]. */ +data class TvSearchCatalog( + val query: String, + val results: List, + val providerCount: Int, + val failedProviderCount: Int = 0, +) + +sealed interface TvSearchUiState { + /** No submitted query yet (or query cleared). */ + data object Idle : TvSearchUiState + + data class Loading( + val query: String, + ) : TvSearchUiState + + data class Content( + val catalog: TvSearchCatalog, + ) : TvSearchUiState + + data class Empty( + val query: String, + val message: String = "No results for this query.", + val failedProviderCount: Int = 0, + val providerCount: Int = 0, + ) : TvSearchUiState + + data class Error( + val query: String, + val message: String, + ) : TvSearchUiState +} + +sealed interface TvSearchAction { + data class UpdateQuery(val query: String) : TvSearchAction + /** Explicit submit — preferred over per-keystroke (mirrors SearchFragment.onQueryTextSubmit). */ + data object Submit : TvSearchAction + data object Clear : TvSearchAction + data object Retry : TvSearchAction +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSettingsModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSettingsModels.kt new file mode 100644 index 00000000000..870f8e48a1d --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSettingsModels.kt @@ -0,0 +1,68 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Immutable TV presentation models for Settings (Phase 11). + * Not a duplicate of [com.lagradost.cloudstream4.AppSettings] — only what the 10ft UI needs. + * Writes always go through existing PreferenceData / AppSettings APIs. + */ + +enum class TvSettingsCategory(val label: String) { + Playback("Playback"), + Appearance("Appearance"), + Language("Language"), + Downloads("Downloads"), + App("App"), +} + +/** How a row is edited on TV. No free-text editors. */ +enum class TvSettingControlKind { + Boolean, + Enum, + Action, + ReadOnly, +} + +data class TvSettingOption( + val key: String, + val label: String, +) + +data class TvSettingItem( + val id: String, + val category: TvSettingsCategory, + val title: String, + val summary: String? = null, + val valueLabel: String? = null, + val control: TvSettingControlKind, + /** Current boolean when [control] is Boolean. */ + val booleanValue: Boolean = false, + /** Options when [control] is Enum. */ + val options: List = emptyList(), + val selectedOptionKey: String? = null, + /** Clear confirm before write — locale / recreate. */ + val requiresRestart: Boolean = false, + val restartMessage: String? = null, +) + +data class TvSettingsSection( + val category: TvSettingsCategory, + val items: List, +) + +data class TvSettingsCatalog( + val sections: List, + /** Local profile name only — no OAuth. */ + val accountDisplayName: String?, +) + +sealed interface TvSettingsUiState { + data object Loading : TvSettingsUiState + data class Ready(val catalog: TvSettingsCatalog) : TvSettingsUiState +} + +sealed interface TvSettingsAction { + data object Refresh : TvSettingsAction + data class ToggleBoolean(val id: String) : TvSettingsAction + data class SelectEnum(val id: String, val optionKey: String) : TvSettingsAction + data class InvokeAction(val id: String) : TvSettingsAction +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt new file mode 100644 index 00000000000..0b8b98c12bb --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt @@ -0,0 +1,147 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Immutable TV models for Continue Watching + Watchlist/Library (Phase 8). + * Only fields present on real DataStore / header-cache sources — no invented metadata. + */ + +/** One Continue Watching row from read-only resume + header cache (+ optional PosDur). */ +data class TvContinueWatchingItem( + val id: String, + val title: String, + val url: String, + val apiName: String, + val posterUrl: String? = null, + val typeLabel: String? = null, + /** Null when PosDur missing or duration <= 0 — never invent progress. */ + val progressFraction: Float? = null, + val episode: Int? = null, + val season: Int? = null, + val parentId: Int? = null, + val episodeId: Int? = null, + val updateTimeMs: Long = 0L, +) { + fun toContentRef(): TvContentRef = TvContentRef( + url = url, + apiName = apiName, + title = title, + ) + + fun toMediaItem(): TvMediaItem { + val classification = TvContinueWatchingClassifier.classify(this) + val availability = TvAvailabilityClassifier.fromCwClassification(classification) + val epSubtitle = buildList { + when { + season != null && episode != null -> add("S$season E$episode") + episode != null -> add("E$episode") + else -> typeLabel?.let { add(it) } + } + when (classification.clazz) { + // Valid resume: poster/title/ep/progress + Resume label (progress on card bar). + TvCwClass.DirectPlayable -> add("Resume") + TvCwClass.PlayableAfterDetails -> { + if (season != null || episode != null || episodeId != null) add("Continue") + else add("Details") + } + // Stale / unsafe: explicit availability label — never fake play. + TvCwClass.NotSafelyPlayable -> add(availability.kind.label) + } + progressFraction?.takeIf { classification.clazz != TvCwClass.NotSafelyPlayable }?.let { + add("${(it * 100).toInt()}%") + } + }.joinToString(" · ") + return TvMediaItem( + id = id, + title = title, + subtitle = epSubtitle, + posterUrl = posterUrl, + backdropUrl = posterUrl, + // Hide fake progress on unavailable cards. + progressFraction = progressFraction.takeIf { + classification.clazz != TvCwClass.NotSafelyPlayable + }, + apiName = apiName, + url = url, + typeLabel = typeLabel, + isMock = false, + resumeHint = TvContinueWatchingClassifier.resumeHintOf(this), + availabilityKind = availability.kind, + ) + } +} + +/** One Library / Watchlist card from bookmarks or favorites (Local list sources). */ +data class TvWatchlistItem( + val id: String, + val title: String, + val url: String, + val apiName: String, + val posterUrl: String? = null, + val year: Int? = null, + val typeLabel: String? = null, + val watchStatusLabel: String? = null, + val latestUpdatedTime: Long = 0L, + val posterHeaders: Map? = null, +) { + fun toContentRef(): TvContentRef = TvContentRef( + url = url, + apiName = apiName, + title = title, + ) + + fun toMediaItem(): TvMediaItem { + val subtitle = buildList { + watchStatusLabel?.let { add(it) } + typeLabel?.let { add(it) } + year?.let { add(it.toString()) } + }.joinToString(" · ") + return TvMediaItem( + id = id, + title = title, + subtitle = subtitle, + posterUrl = posterUrl, + backdropUrl = posterUrl, + year = year, + typeLabel = typeLabel, + apiName = apiName, + url = url, + posterHeaders = posterHeaders, + isMock = false, + ) + } +} + +data class TvWatchlistSection( + val id: String, + val title: String, + val items: List, +) + +data class TvWatchlistCatalog( + val sections: List, + /** Local CloudStream profile key index as string (DataStoreHelper.currentAccount). */ + val accountKey: String, + val accountName: String? = null, +) + +sealed interface TvWatchlistUiState { + data object Loading : TvWatchlistUiState + + data class Content( + val catalog: TvWatchlistCatalog, + ) : TvWatchlistUiState + + data class Empty( + val message: String = "No titles in Library yet. Mark watch status or favorites on phone/tablet.", + val accountName: String? = null, + ) : TvWatchlistUiState + + data class Error( + val message: String, + ) : TvWatchlistUiState +} + +sealed interface TvWatchlistAction { + data object Retry : TvWatchlistAction + data object Refresh : TvWatchlistAction +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt new file mode 100644 index 00000000000..53ce3c35759 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -0,0 +1,217 @@ +package com.lagradost.cloudstream3.tv.navigation + +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.tv.material3.DrawerValue +import androidx.tv.material3.Icon +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.NavigationDrawer +import androidx.tv.material3.NavigationDrawerItem +import androidx.tv.material3.Text +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.tv.TvProbeScreen +import com.lagradost.cloudstream3.tv.details.TvDetailsScreen +import com.lagradost.cloudstream3.tv.home.TvHomeScreen +import com.lagradost.cloudstream3.tv.home.rememberTvHomeFocusState +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvDestination +import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest +import com.lagradost.cloudstream3.tv.model.TvResumeHint +import com.lagradost.cloudstream3.tv.search.TvSearchScreen +import com.lagradost.cloudstream3.tv.settings.TvSettingsScreen +import com.lagradost.cloudstream3.tv.search.rememberTvSearchFocusState +import com.lagradost.cloudstream3.tv.watchlist.TvWatchlistScreen +import com.lagradost.cloudstream3.tv.watchlist.rememberTvWatchlistFocusState + +/** + * Structural Compose TV shell: left nav + destination content. + * Phase 11: Settings over existing AppSettings; Phase 10 playback pipeline unchanged. + * Watchlist → same TvDetailsScreen. Mock never becomes real TvPlaybackRequest. + */ +@Composable +fun TvNavigationShell( + modifier: Modifier = Modifier, + onPlaybackRequest: (TvPlaybackRequest) -> Unit = {}, +) { + var destination by rememberSaveable { mutableStateOf(TvDestination.Home.name) } + val selected = runCatching { TvDestination.valueOf(destination) }.getOrDefault(TvDestination.Home) + val homeFocusState = rememberTvHomeFocusState() + val searchFocusState = rememberTvSearchFocusState() + val watchlistFocusState = rememberTvWatchlistFocusState() + var showFocusProbe by rememberSaveable { mutableStateOf(false) } + + // Compact saveable identity — never store SearchResponse / LoadResponse here. + var detailsUrl by rememberSaveable { mutableStateOf(null) } + var detailsApiName by rememberSaveable { mutableStateOf(null) } + var detailsTitle by rememberSaveable { mutableStateOf(null) } + // Phase 9 CW restore hints encoded as strings for rememberSaveable. + var detailsResumeSeason by rememberSaveable { mutableStateOf(null) } + var detailsResumeEpisode by rememberSaveable { mutableStateOf(null) } + var detailsResumeEpisodeId by rememberSaveable { mutableStateOf(null) } + var detailsResumeParentId by rememberSaveable { mutableStateOf(null) } + var detailsResumeTypeLabel by rememberSaveable { mutableStateOf(null) } + var detailsHasResumeHint by rememberSaveable { mutableStateOf(false) } + + val detailsRef = run { + val url = detailsUrl + val api = detailsApiName + if (!url.isNullOrBlank() && !api.isNullOrBlank()) { + val hint = if (detailsHasResumeHint) { + TvResumeHint( + season = detailsResumeSeason?.toIntOrNull(), + episode = detailsResumeEpisode?.toIntOrNull(), + episodeId = detailsResumeEpisodeId?.toIntOrNull(), + parentId = detailsResumeParentId?.toIntOrNull(), + typeLabel = detailsResumeTypeLabel, + ) + } else { + null + } + TvContentRef( + url = url, + apiName = api, + title = detailsTitle.orEmpty(), + resumeHint = hint, + ) + } else { + null + } + } + + fun openDetails(ref: TvContentRef) { + detailsUrl = ref.url + detailsApiName = ref.apiName + detailsTitle = ref.title + val hint = ref.resumeHint + detailsHasResumeHint = hint != null + detailsResumeSeason = hint?.season?.toString() + detailsResumeEpisode = hint?.episode?.toString() + detailsResumeEpisodeId = hint?.episodeId?.toString() + detailsResumeParentId = hint?.parentId?.toString() + detailsResumeTypeLabel = hint?.typeLabel + } + + fun closeDetails() { + detailsUrl = null + detailsApiName = null + detailsTitle = null + detailsHasResumeHint = false + detailsResumeSeason = null + detailsResumeEpisode = null + detailsResumeEpisodeId = null + detailsResumeParentId = null + detailsResumeTypeLabel = null + } + + fun guardedPlayback(request: TvPlaybackRequest) { + if (request.isMock) return // Mock never becomes real playback. + onPlaybackRequest(request) + } + + NavigationDrawer( + modifier = modifier.fillMaxSize(), + drawerContent = { + val drawerOpen = it == DrawerValue.Open + Column( + modifier = Modifier + .background(MaterialTheme.colorScheme.surface) + .fillMaxHeight() + .padding(12.dp) + .selectableGroup(), + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically), + ) { + Text( + text = if (drawerOpen) "CloudStream" else "CS", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp, bottom = 12.dp), + ) + TvDestination.entries.forEach { dest -> + val iconRes = when (dest) { + TvDestination.Home -> R.drawable.home_icon_outline_24 + TvDestination.Search -> R.drawable.search_icon + TvDestination.Watchlist -> R.drawable.ic_baseline_bookmark_border_24 + TvDestination.Settings -> R.drawable.ic_outline_settings_24 + } + NavigationDrawerItem( + selected = selected == dest && detailsRef == null, + onClick = { + closeDetails() + destination = dest.name + if (dest != TvDestination.Settings) showFocusProbe = false + }, + leadingContent = { + Icon( + painter = painterResource(iconRes), + contentDescription = dest.label, + ) + }, + ) { + Text(dest.label) + } + } + } + }, + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) + .padding(start = 8.dp, top = 8.dp, end = 24.dp, bottom = 8.dp), + ) { + when { + detailsRef != null -> { + TvDetailsScreen( + ref = detailsRef, + onBack = { closeDetails() }, + onPlaybackRequest = ::guardedPlayback, + ) + } + selected == TvDestination.Home -> { + TvHomeScreen( + focusState = homeFocusState, + onOpenDetails = { openDetails(it) }, + onPlaybackRequest = ::guardedPlayback, + ) + } + selected == TvDestination.Search -> { + TvSearchScreen( + focusState = searchFocusState, + onOpenDetails = { openDetails(it) }, + ) + } + selected == TvDestination.Watchlist -> { + TvWatchlistScreen( + focusState = watchlistFocusState, + onOpenDetails = { openDetails(it) }, + ) + } + selected == TvDestination.Settings -> { + if (showFocusProbe) { + TvProbeScreen() + } else { + TvSettingsScreen( + onOpenFocusProbe = { showFocusProbe = true }, + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/playback/TvPlaybackBridge.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/playback/TvPlaybackBridge.kt new file mode 100644 index 00000000000..8c0da4708b6 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/playback/TvPlaybackBridge.kt @@ -0,0 +1,292 @@ +package com.lagradost.cloudstream3.tv.playback + +import android.util.Log +import androidx.fragment.app.FragmentActivity +import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull +import com.lagradost.cloudstream3.APIHolder.getApiFromUrlNull +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.CommonActivity.showToast +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.metaproviders.SyncRedirector +import com.lagradost.cloudstream3.mvvm.Resource +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.mvvm.safeApiCall +import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest +import com.lagradost.cloudstream3.ui.APIRepository +import com.lagradost.cloudstream3.ui.player.GeneratorPlayer +import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator +import com.lagradost.cloudstream3.ui.result.buildResultEpisode +import com.lagradost.cloudstream3.ui.result.getId +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Smallest Activity/Fragment boundary from Compose TV → existing CloudStream playback. + * + * Does **not** contain Compose UI, custom player controls, extractors, or a new network stack. + * + * ``` + * MovieLoadResponse | selected Episode fields + * → buildResultEpisode (same fields as ResultViewModel2) + * → RepoLinkGenerator(listOf(ep), page = loadResponse) + * → GeneratorPlayer.newInstance(generator, index=0, syncData) + * → FragmentTransaction into [R.id.tv_player_container] + * → GeneratorPlayer.loadLinks → RepoLinkGenerator.generateLinks → APIRepository.loadLinks + * → CS3IPlayer / Media3 (unchanged) + * ``` + * + * Phase 6: Movies (Watch Now) + TvSeries/Anime episode path. Live / Torrent rejected. + * Mock / comingSoon never play. No second player or extractor path. + */ +object TvPlaybackBridge { + private const val TAG = "TvPlaybackBridge" + const val PLAYER_BACK_STACK = "tv_compose_generator_player" + + sealed interface LaunchResult { + data object Launched : LaunchResult + data object RejectedMock : LaunchResult + data class Unsupported(val reason: String) : LaunchResult + data class Failed(val message: String) : LaunchResult + } + + private sealed interface PrepResult { + data class Ready( + val generator: RepoLinkGenerator, + val syncData: HashMap, + ) : PrepResult + + data class Failed(val message: String) : PrepResult + } + + private sealed interface LoadOutcome { + data class Failed(val message: String) : LoadOutcome + data class Loaded(val response: LoadResponse) : LoadOutcome + } + + /** + * Suspends for the same load path as details (APIRepository.load), then commits + * [GeneratorPlayer] on the UI thread. Caller must be an Activity that hosts + * [R.id.tv_player_container] (see activity_tv_compose_probe.xml). + */ + suspend fun launch( + activity: FragmentActivity, + request: TvPlaybackRequest, + ): LaunchResult { + if (request.isMock) { + Log.i(TAG, "Rejected mock/demo playback for ${request.title}") + return LaunchResult.RejectedMock + } + if (request.comingSoon) { + return LaunchResult.Unsupported("Coming soon — not released yet") + } + + val supported = when { + request.isMovie -> true + request.isEpisodePlayback && + (request.variantLabel == "TvSeries" || request.variantLabel == "Anime") -> true + else -> false + } + if (!supported) { + val reason = unsupportedReason(request.variantLabel) + Log.i(TAG, "Unsupported variant ${request.variantLabel}: $reason") + return LaunchResult.Unsupported(reason) + } + + if (!request.isMovie && request.episodeData.isNullOrBlank()) { + return LaunchResult.Failed("Selected episode has no playable data") + } + + val prepared = withContext(Dispatchers.IO) { + runCatching { + if (request.isMovie) prepareMovieGenerator(request) + else prepareEpisodeGenerator(request) + } + .onFailure { logError(it) } + .getOrElse { + PrepResult.Failed(it.message ?: "Failed to prepare playback") + } + } + + return when (prepared) { + is PrepResult.Failed -> LaunchResult.Failed(prepared.message) + is PrepResult.Ready -> { + commitPlayer(activity, prepared) + LaunchResult.Launched + } + } + } + + fun unsupportedReason(variantLabel: String): String = when (variantLabel) { + "TvSeries", "Anime" -> "Select a playable episode first" + "LiveStream" -> "Live playback is not supported in Compose TV yet" + "Torrent" -> "Torrent playback is not supported in Compose TV yet" + else -> "Playback for $variantLabel is not supported" + } + + fun report(activity: FragmentActivity, result: LaunchResult) { + when (result) { + LaunchResult.Launched -> Unit + LaunchResult.RejectedMock -> + showToast(activity, "Demo items cannot start real playback", null) + is LaunchResult.Unsupported -> + showToast(activity, result.reason, null) + is LaunchResult.Failed -> + showToast(activity, result.message, null) + } + } + + private suspend fun resolveLoad(request: TvPlaybackRequest): LoadOutcome { + if (APIRepository.isInvalidData(request.url)) { + return LoadOutcome.Failed("Invalid content URL") + } + val api = getApiFromNameNull(request.apiName) ?: getApiFromUrlNull(request.url) + ?: return LoadOutcome.Failed( + "This provider does not exist (${request.apiName}). Retry after plugins finish loading.", + ) + + val validUrlResource = safeApiCall { SyncRedirector.redirect(request.url, api) } + val validUrl = when (validUrlResource) { + is Resource.Success -> validUrlResource.value + is Resource.Failure -> { + return LoadOutcome.Failed( + validUrlResource.errorString.ifBlank { + "Failed to resolve content URL for ${request.apiName}" + }, + ) + } + is Resource.Loading -> request.url + } + + return when (val load = APIRepository(api).load(validUrl)) { + is Resource.Success -> LoadOutcome.Loaded(load.value) + is Resource.Failure -> LoadOutcome.Failed( + load.errorString.ifBlank { "Failed to load ${request.title}" }, + ) + is Resource.Loading -> LoadOutcome.Failed( + "Unexpected loading state from APIRepository.load", + ) + } + } + + private suspend fun prepareMovieGenerator(request: TvPlaybackRequest): PrepResult { + return when (val outcome = resolveLoad(request)) { + is LoadOutcome.Failed -> PrepResult.Failed(outcome.message) + is LoadOutcome.Loaded -> { + val movie = outcome.response as? MovieLoadResponse + ?: return PrepResult.Failed( + "Expected MovieLoadResponse, got ${outcome.response::class.simpleName}", + ) + if (movie.dataUrl.isBlank()) { + return PrepResult.Failed("Movie has no playable data URL") + } + val episode = movieToResultEpisode(movie) + val generator = RepoLinkGenerator(listOf(episode), page = movie) + Log.i(TAG, "Prepared RepoLinkGenerator for movie id=${episode.id} api=${movie.apiName}") + PrepResult.Ready(generator, HashMap(movie.syncData)) + } + } + } + + /** + * Series / Anime — same GeneratorPlayer entry as movies, with episode ResultEpisode. + * Request carries Episode.data + metadata; page = reloaded LoadResponse. + */ + private suspend fun prepareEpisodeGenerator(request: TvPlaybackRequest): PrepResult { + return when (val outcome = resolveLoad(request)) { + is LoadOutcome.Failed -> PrepResult.Failed(outcome.message) + is LoadOutcome.Loaded -> { + val response = outcome.response + if (response !is TvSeriesLoadResponse && response !is AnimeLoadResponse) { + return PrepResult.Failed( + "Expected series/anime LoadResponse, got ${response::class.simpleName}", + ) + } + val data = request.episodeData?.takeIf { it.isNotBlank() } + ?: return PrepResult.Failed("Episode has no playable data") + val episodeId = request.episodeId + ?: return PrepResult.Failed("Episode id missing") + val episodeNumber = request.episodeNumber + ?: return PrepResult.Failed("Episode number missing") + val parentId = response.getId() + + val episode = buildResultEpisode( + headerName = response.name, + name = request.episodeName, + poster = request.episodePoster, + episode = episodeNumber, + seasonIndex = request.seasonIndex, + season = request.displaySeason, + data = data, + apiName = response.apiName, + id = episodeId, + index = request.episodeIndex ?: 0, + rating = null, + description = request.episodeDescription, + isFiller = null, + tvType = response.type, + parentId = parentId, + totalEpisodeIndex = request.totalEpisodeIndex, + airDate = request.airDate, + runTime = request.runTime, + seasonData = null, + ) + val generator = RepoLinkGenerator(listOf(episode), page = response) + Log.i( + TAG, + "Prepared RepoLinkGenerator for episode id=$episodeId " + + "S${request.seasonIndex ?: "-"}E$episodeNumber api=${response.apiName}", + ) + PrepResult.Ready(generator, HashMap(response.syncData)) + } + } + } + + private fun movieToResultEpisode(loadResponse: MovieLoadResponse) = + buildResultEpisode( + headerName = loadResponse.name, + name = loadResponse.name, + poster = null, + episode = 0, + seasonIndex = null, + season = null, + data = loadResponse.dataUrl, + apiName = loadResponse.apiName, + id = loadResponse.getId(), + index = 0, + rating = null, + description = null, + isFiller = null, + tvType = loadResponse.type, + parentId = loadResponse.getId(), + totalEpisodeIndex = null, + ) + + private fun commitPlayer(activity: FragmentActivity, ready: PrepResult.Ready) { + activity.runOnUiThread { + if (activity.isFinishing || activity.isDestroyed) return@runOnUiThread + val containerId = R.id.tv_player_container + if (activity.findViewById(containerId) == null) { + Log.e(TAG, "Missing R.id.tv_player_container — cannot host GeneratorPlayer") + showToast(activity, "Player host missing in Activity layout", null) + return@runOnUiThread + } + val args = GeneratorPlayer.newInstance(ready.generator, 0, ready.syncData) + val fragment = GeneratorPlayer().apply { arguments = args } + val fm = activity.supportFragmentManager + if (fm.findFragmentByTag(PLAYER_BACK_STACK) != null) { + fm.popBackStack( + PLAYER_BACK_STACK, + androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE, + ) + } + fm.beginTransaction() + .replace(containerId, fragment, PLAYER_BACK_STACK) + .addToBackStack(PLAYER_BACK_STACK) + .commit() + Log.i(TAG, "Committed GeneratorPlayer into tv_player_container") + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchScreen.kt new file mode 100644 index 00000000000..e9536c36f6a --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchScreen.kt @@ -0,0 +1,382 @@ +package com.lagradost.cloudstream3.tv.search + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import androidx.tv.material3.WideButton +import androidx.tv.material3.WideButtonDefaults +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.components.TvMediaCard +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvSearchAction +import com.lagradost.cloudstream3.tv.model.TvSearchCatalog +import com.lagradost.cloudstream3.tv.model.TvSearchResult +import com.lagradost.cloudstream3.tv.model.TvAvailabilityClassifier +import com.lagradost.cloudstream3.tv.model.TvAvailabilityKind +import com.lagradost.cloudstream3.tv.model.TvSearchUiState + +/** + * Hoisted Search focus memory — survives Details overlay in [com.lagradost.cloudstream3.tv.navigation.TvNavigationShell]. + * Query text lives in [TvSearchViewModel]; this only tracks field vs results focus + selected index. + */ +class TvSearchFocusState { + var lastFocusedResultIndex: Int by mutableStateOf(0) + var restoreToField: Boolean by mutableStateOf(true) + var initialFocusDone: Boolean by mutableStateOf(false) + + fun pruneTo(resultCount: Int) { + if (resultCount <= 0) { + lastFocusedResultIndex = 0 + } else { + lastFocusedResultIndex = lastFocusedResultIndex.coerceIn(0, resultCount - 1) + } + } +} + +@Composable +fun rememberTvSearchFocusState(): TvSearchFocusState = remember { TvSearchFocusState() } + +@Composable +fun TvSearchScreen( + focusState: TvSearchFocusState, + modifier: Modifier = Modifier, + onOpenDetails: (TvContentRef) -> Unit = {}, + viewModel: TvSearchViewModel = viewModel(), +) { + val uiState by viewModel.state.collectAsState() + val query by viewModel.queryText.collectAsState() + val fieldFocus = remember { FocusRequester() } + val keyboard = LocalSoftwareKeyboardController.current + + LaunchedEffect(Unit) { + if (!focusState.initialFocusDone) { + runCatching { fieldFocus.requestFocus() } + focusState.initialFocusDone = true + focusState.restoreToField = true + } + } + + Column( + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = "Search", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + TvSearchInputRow( + query = query, + fieldFocus = fieldFocus, + onQueryChange = { viewModel.onAction(TvSearchAction.UpdateQuery(it)) }, + onSubmit = { + keyboard?.hide() + focusState.restoreToField = false + viewModel.onAction(TvSearchAction.Submit) + }, + onClear = { + viewModel.onAction(TvSearchAction.Clear) + focusState.lastFocusedResultIndex = 0 + focusState.restoreToField = true + runCatching { fieldFocus.requestFocus() } + }, + restoreFieldFocus = focusState.restoreToField && uiState !is TvSearchUiState.Content, + onFieldFocused = { focusState.restoreToField = true }, + ) + + when (val state = uiState) { + TvSearchUiState.Idle -> TvSearchHintPane( + body = "Type a title, then press Search (or IME Done). Results open the same Details as Home.", + ) + is TvSearchUiState.Loading -> TvSearchHintPane( + body = "Searching for \"${state.query}\"…", + ) + is TvSearchUiState.Content -> { + focusState.pruneTo(state.catalog.results.size) + TvSearchResultsGrid( + catalog = state.catalog, + focusState = focusState, + onOpen = { result -> + focusState.restoreToField = false + onOpenDetails(result.contentRef) + }, + ) + } + is TvSearchUiState.Empty -> TvSearchStatusPane( + title = TvAvailabilityKind.Unavailable.label, + body = buildString { + append(state.message) + if (state.providerCount > 0) { + append("\nSearched ${state.providerCount} provider(s).") + } + if (state.failedProviderCount > 0) { + append("\n${state.failedProviderCount} provider(s) failed (partial).") + } + append("\n\nTry another query, or retry. No silent swap to other content.") + }, + primaryLabel = "Retry", + onPrimary = { viewModel.onAction(TvSearchAction.Retry) }, + secondaryLabel = "Clear", + onSecondary = { + viewModel.onAction(TvSearchAction.Clear) + focusState.restoreToField = true + runCatching { fieldFocus.requestFocus() } + }, + ) + is TvSearchUiState.Error -> { + val status = TvAvailabilityClassifier.fromSearchFailure(state.message) + TvSearchStatusPane( + title = status.title, + body = state.message + "\n\nRetry or Clear. No silent swap to other results.", + primaryLabel = "Retry", + onPrimary = { viewModel.onAction(TvSearchAction.Retry) }, + secondaryLabel = "Clear", + onSecondary = { + viewModel.onAction(TvSearchAction.Clear) + focusState.restoreToField = true + runCatching { fieldFocus.requestFocus() } + }, + ) + } + } + } +} + +@Composable +private fun TvSearchInputRow( + query: String, + fieldFocus: FocusRequester, + onQueryChange: (String) -> Unit, + onSubmit: () -> Unit, + onClear: () -> Unit, + restoreFieldFocus: Boolean, + onFieldFocused: () -> Unit, +) { + var fieldFocused by remember { mutableStateOf(false) } + + LaunchedEffect(restoreFieldFocus) { + if (restoreFieldFocus) { + runCatching { fieldFocus.requestFocus() } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .weight(1f) + .height(56.dp) + .background( + color = if (fieldFocused) { + MaterialTheme.colorScheme.surfaceVariant + } else { + MaterialTheme.colorScheme.surface + }, + shape = RoundedCornerShape(12.dp), + ) + .padding(horizontal = 20.dp, vertical = 14.dp), + contentAlignment = Alignment.CenterStart, + ) { + if (query.isEmpty()) { + Text( + text = "Search movies, series, anime…", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.titleLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSubmit() }), + modifier = Modifier + .fillMaxWidth() + .focusRequester(fieldFocus) + .onFocusChanged { state -> + fieldFocused = state.isFocused + if (state.isFocused) onFieldFocused() + }, + ) + } + + WideButton( + onClick = onSubmit, + scale = WideButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + modifier = Modifier.width(160.dp), + ) { + Text("Search") + } + + WideButton( + onClick = onClear, + scale = WideButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + modifier = Modifier.width(140.dp), + ) { + Text("Clear") + } + } +} + +@Composable +private fun TvSearchResultsGrid( + catalog: TvSearchCatalog, + focusState: TvSearchFocusState, + onOpen: (TvSearchResult) -> Unit, +) { + val safeIndex = focusState.lastFocusedResultIndex.coerceIn(0, catalog.results.lastIndex.coerceAtLeast(0)) + val focusRequesters = remember(catalog.query, catalog.results.size) { + List(catalog.results.size) { FocusRequester() } + } + val gridState = rememberLazyGridState() + var restorePending by remember(catalog.query) { mutableStateOf(true) } + + LaunchedEffect(restorePending, catalog.query, safeIndex, catalog.results.size) { + if (restorePending && focusRequesters.isNotEmpty() && !focusState.restoreToField) { + gridState.scrollToItem(safeIndex) + runCatching { focusRequesters[safeIndex].requestFocus() } + restorePending = false + } + } + + Column(Modifier.fillMaxSize()) { + val subtitle = buildString { + append("${catalog.results.size} result(s) for \"${catalog.query}\"") + if (catalog.failedProviderCount > 0) { + append(" · ${catalog.failedProviderCount}/${catalog.providerCount} provider(s) failed") + } + } + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 148.dp), + state = gridState, + contentPadding = PaddingValues(8.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.fillMaxSize(), + ) { + itemsIndexed(catalog.results, key = { _, item -> item.id }) { index, item -> + TvMediaCard( + item = item.toMediaItem(), + onClick = { onOpen(item) }, + onFocused = { focusState.lastFocusedResultIndex = index }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } +} + +@Composable +private fun TvSearchHintPane(body: String) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.TopStart, + ) { + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TvSearchStatusPane( + title: String, + body: String, + primaryLabel: String, + onPrimary: () -> Unit, + secondaryLabel: String? = null, + onSecondary: (() -> Unit)? = null, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + WideButton( + onClick = onPrimary, + scale = WideButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(primaryLabel) + } + if (secondaryLabel != null && onSecondary != null) { + WideButton( + onClick = onSecondary, + scale = WideButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(secondaryLabel) + } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchViewModel.kt new file mode 100644 index 00000000000..21ee7b1f440 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchViewModel.kt @@ -0,0 +1,132 @@ +package com.lagradost.cloudstream3.tv.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.tv.data.TvSearchRepository +import com.lagradost.cloudstream3.tv.model.TvSearchAction +import com.lagradost.cloudstream3.tv.model.TvSearchUiState +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicInteger + +/** + * Lifecycle-aware Search state holder (MVI / [StateContainer]). + * Explicit submit only — no per-keystroke search (mobile SearchFragment submits on IME Done; + * DebounceQuery exists in shared compose but is unused by production search). + * + * Cancels superseded searches via job cancel + generation counter so stale results never overwrite. + * Query text lives in [queryText] (separate from result UiState) so Idle/Loading/Content all + * preserve the field when returning from Details. + */ +class TvSearchViewModel( + private val repository: TvSearchRepository = TvSearchRepository(), +) : ViewModel(), + StateContainer by DefaultStateContainer(TvSearchUiState.Idle), + ActionHandler { + + /** Draft query shown in the search field — survives Details overlay (Activity ViewModelStore). */ + private val _queryText = kotlinx.coroutines.flow.MutableStateFlow("") + val queryText: kotlinx.coroutines.flow.StateFlow = _queryText + + private var searchJob: Job? = null + private val searchGeneration = AtomicInteger(0) + + override fun onAction(action: TvSearchAction) { + when (action) { + is TvSearchAction.UpdateQuery -> { + _queryText.value = action.query + } + TvSearchAction.Submit -> submitSearch(_queryText.value) + TvSearchAction.Clear -> clearSearch() + TvSearchAction.Retry -> { + val q = when (val s = state.value) { + is TvSearchUiState.Error -> s.query + is TvSearchUiState.Empty -> s.query + is TvSearchUiState.Content -> s.catalog.query + is TvSearchUiState.Loading -> s.query + TvSearchUiState.Idle -> _queryText.value + } + if (q.isNotBlank()) { + _queryText.value = q + submitSearch(q) + } + } + } + } + + private fun clearSearch() { + searchJob?.cancel() + searchGeneration.incrementAndGet() + _queryText.value = "" + updateState { TvSearchUiState.Idle } + } + + private fun submitSearch(raw: String) { + val query = raw.trim() + if (query.length <= 1) { + updateState { + TvSearchUiState.Error( + query = query, + message = "Enter at least 2 characters, then press Search.", + ) + } + return + } + + searchJob?.cancel() + val generation = searchGeneration.incrementAndGet() + searchJob = viewModelScope.launch { + updateState { TvSearchUiState.Loading(query) } + val result = try { + withContext(Dispatchers.IO) { + repository.search(query) { searchGeneration.get() == generation } + } + } catch (t: Throwable) { + if (t is kotlinx.coroutines.CancellationException) throw t + logError(t) + TvSearchRepository.SearchResult.Failure( + query = query, + message = t.message ?: "Unexpected search error", + ) + } + + // Stale / cancelled — do not overwrite newer state. + if (searchGeneration.get() != generation) return@launch + + updateState { + when (result) { + is TvSearchRepository.SearchResult.Success -> + TvSearchUiState.Content(result.catalog) + + is TvSearchRepository.SearchResult.Empty -> + TvSearchUiState.Empty( + query = result.query, + message = buildString { + append("No results for \"${result.query}\".") + if (result.failedProviderCount > 0) { + append(" (${result.failedProviderCount} provider(s) failed)") + } + }, + failedProviderCount = result.failedProviderCount, + providerCount = result.providerCount, + ) + + is TvSearchRepository.SearchResult.Failure -> { + if (result.message == "Search cancelled") { + // Keep prior non-loading state if cancelled mid-flight by a newer search. + this + } else { + TvSearchUiState.Error(result.query, result.message) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsAdapter.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsAdapter.kt new file mode 100644 index 00000000000..2ae384b0216 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsAdapter.kt @@ -0,0 +1,467 @@ +package com.lagradost.cloudstream3.tv.settings + +import android.content.Context +import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.CloudStreamApp +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.UnsafeSSL +import com.lagradost.cloudstream3.app +import com.lagradost.cloudstream3.insecureApp +import com.lagradost.cloudstream3.network.initClient +import com.lagradost.cloudstream3.tv.model.TvSettingControlKind +import com.lagradost.cloudstream3.tv.model.TvSettingItem +import com.lagradost.cloudstream3.tv.model.TvSettingOption +import com.lagradost.cloudstream3.tv.model.TvSettingsCatalog +import com.lagradost.cloudstream3.tv.model.TvSettingsCategory +import com.lagradost.cloudstream3.tv.model.TvSettingsSection +import com.lagradost.cloudstream3.ui.settings.appLanguages +import com.lagradost.cloudstream3.ui.settings.getCurrentLocale +import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji +import com.lagradost.cloudstream3.utils.DataStoreHelper +import com.lagradost.cloudstream4.AppSettings +import com.mihon.common.preference.PreferenceData + +/** + * Adapts EXISTING [AppSettings] / PreferenceManager prefs into TV presentation rows. + * Architectural Q: YES — same underlying CloudStream preference system (no parallel store). + */ +class TvSettingsAdapter( + private val context: Context, + private val settings: AppSettings, +) { + fun buildCatalog(): TvSettingsCatalog { + val sections = listOfNotNull( + section(TvSettingsCategory.Playback, playbackItems()), + section(TvSettingsCategory.Appearance, appearanceItems()), + section(TvSettingsCategory.Language, languageItems()), + section(TvSettingsCategory.Downloads, downloadItems()), + section(TvSettingsCategory.App, appItems()), + ) + val account = DataStoreHelper.getCurrentAccount() + ?: runCatching { DataStoreHelper.getDefaultAccount(context) }.getOrNull() + return TvSettingsCatalog( + sections = sections, + accountDisplayName = account?.name, + ) + } + + fun toggleBoolean(id: String): ApplyResult { + val pref = booleanPref(id) ?: return ApplyResult.Unknown + pref.set(!pref.get()) + return ApplyResult.Applied + } + + fun selectEnum(id: String, optionKey: String): ApplyResult { + return when (id) { + ID_SOFTWARE_DECODING -> { + settings.player.softwareDecoding.set(optionKey.toInt()) + ApplyResult.Applied + } + ID_TV_SEEK_ON -> { + settings.player.tvSeekOnTime.set(optionKey.toInt()) + ApplyResult.Applied + } + ID_TV_SEEK_OFF -> { + settings.player.tvSeekOffTime.set(optionKey.toInt()) + ApplyResult.Applied + } + ID_CONFIRM_EXIT -> { + settings.ui.confirmExit.set(optionKey.toInt()) + ApplyResult.Applied + } + ID_LOCALE -> { + settings.general.locale.set(optionKey) + ApplyResult.NeedsRecreate + } + ID_DNS -> { + settings.general.dns.set(optionKey.toInt()) + // Same downstream as phone SettingsGeneralScreen ListPreference onValueChanged. + CloudStreamApp.context?.let { ctx -> + app.initClient(ctx, ignoreSSL = false) + @OptIn(UnsafeSSL::class) + insecureApp.initClient(ctx, ignoreSSL = true) + } + ApplyResult.Applied + } + ID_PARALLEL_DOWNLOADS -> { + settings.general.parallelDownloads.set(optionKey.toInt()) + ApplyResult.Applied + } + ID_CONCURRENT_CONNECTIONS -> { + settings.general.concurrentConnections.set(optionKey.toInt()) + ApplyResult.Applied + } + else -> ApplyResult.Unknown + } + } + + sealed interface ApplyResult { + data object Applied : ApplyResult + data object NeedsRecreate : ApplyResult + data object Unknown : ApplyResult + } + + private fun section( + category: TvSettingsCategory, + items: List, + ): TvSettingsSection? { + if (items.isEmpty()) return null + return TvSettingsSection(category, items) + } + + private fun playbackItems(): List = listOf( + boolItem( + id = ID_AUTOPLAY, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.autoplay_next_settings), + summary = context.getString(R.string.autoplay_next_settings_des), + pref = settings.player.autoPlayEnabled, + ), + boolItem( + id = ID_SKIP_OP, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.video_skip_op), + summary = context.getString(R.string.enable_skip_op_from_database_des), + pref = settings.player.skipOpEnabled, + ), + boolItem( + id = ID_EPISODE_SYNC, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.episode_sync_settings), + summary = context.getString(R.string.episode_sync_settings_des), + pref = settings.player.episodeSync, + ), + boolItem( + id = ID_START_PAUSED, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.start_paused_settings), + summary = context.getString(R.string.start_paused_settings_des), + pref = settings.player.startPaused, + ), + boolItem( + id = ID_SPEED, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.eigengraumode_settings), + summary = context.getString(R.string.speed_setting_summary), + pref = settings.player.speedEnabled, + ), + enumItem( + id = ID_SOFTWARE_DECODING, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.software_decoding), + summary = context.getString(R.string.software_decoding_desc), + options = zipIntString( + R.array.software_decoding_switch_values, + R.array.software_decoding_switch, + ), + selectedKey = settings.player.softwareDecoding.get().toString(), + ), + enumItem( + id = ID_TV_SEEK_ON, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.android_tv_interface_on_seek_settings), + summary = context.getString(R.string.android_tv_interface_on_seek_settings_summary), + options = seekSecondOptions(), + selectedKey = nearestSeekKey(settings.player.tvSeekOnTime.get()), + ), + enumItem( + id = ID_TV_SEEK_OFF, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.android_tv_interface_off_seek_settings), + summary = context.getString(R.string.android_tv_interface_off_seek_settings_summary), + options = seekSecondOptions(), + selectedKey = nearestSeekKey(settings.player.tvSeekOffTime.get()), + ), + boolItem( + id = ID_SHOW_NAME, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.source_name), + pref = settings.player.showName, + ), + boolItem( + id = ID_SHOW_RESOLUTION, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.resolution), + pref = settings.player.showResolution, + ), + boolItem( + id = ID_SHOW_MEDIA_INFO, + category = TvSettingsCategory.Playback, + title = context.getString(R.string.video_info), + pref = settings.player.showMediaInfo, + ), + ) + + private fun appearanceItems(): List = listOf( + boolItem( + id = ID_TRAILERS, + category = TvSettingsCategory.Appearance, + title = context.getString(R.string.show_trailers_settings), + pref = settings.ui.trailersEnabled, + ), + boolItem( + id = ID_KITSU, + category = TvSettingsCategory.Appearance, + title = context.getString(R.string.kitsu_settings), + pref = settings.ui.kitsuPostersEnabled, + ), + boolItem( + id = ID_CAST, + category = TvSettingsCategory.Appearance, + title = context.getString(R.string.show_cast_in_details), + pref = settings.ui.castEnabled, + ), + boolItem( + id = ID_FILLERS, + category = TvSettingsCategory.Appearance, + title = context.getString(R.string.show_fillers_settings), + pref = settings.ui.fillersEnabled, + ), + boolItem( + id = ID_CLOCK, + category = TvSettingsCategory.Appearance, + title = context.getString(R.string.tv_layout_clock_settings), + summary = context.getString(R.string.tv_layout_clock_settings_des), + pref = settings.ui.showClock, + ), + boolItem( + id = ID_METADATA_OVERLAY, + category = TvSettingsCategory.Appearance, + title = context.getString(R.string.show_player_metadata_overlay), + pref = settings.ui.showMetadataOverlay, + ), + enumItem( + id = ID_CONFIRM_EXIT, + category = TvSettingsCategory.Appearance, + title = context.getString(R.string.confirm_before_exiting_title), + summary = context.getString(R.string.confirm_before_exiting_desc), + options = zipIntString(R.array.confirm_exit_values, R.array.confirm_exit), + selectedKey = settings.ui.confirmExit.get().toString(), + ), + ) + + private fun languageItems(): List { + val options = appLanguages.map { pair -> + TvSettingOption( + key = pair.second, + label = pair.nameNextToFlagEmoji(), + ) + } + val current = settings.general.locale.get().ifBlank { getCurrentLocale(context) } + val selected = options.firstOrNull { it.key.equals(current, ignoreCase = true) }?.key + ?: options.firstOrNull { current.startsWith(it.key, ignoreCase = true) }?.key + ?: current + return listOf( + enumItem( + id = ID_LOCALE, + category = TvSettingsCategory.Language, + title = context.getString(R.string.app_language), + options = options, + selectedKey = selected, + requiresRestart = true, + restartMessage = context.getString(R.string.apply_on_restart), + ), + ) + } + + private fun downloadItems(): List { + val countOptions = (1..10).map { n -> + TvSettingOption(key = n.toString(), label = n.toString()) + } + return listOf( + enumItem( + id = ID_PARALLEL_DOWNLOADS, + category = TvSettingsCategory.Downloads, + title = context.getString(R.string.parallel_downloads), + summary = context.getString(R.string.download_parallel_settings_des), + options = countOptions, + selectedKey = settings.general.parallelDownloads.get().coerceIn(1, 10).toString(), + ), + enumItem( + id = ID_CONCURRENT_CONNECTIONS, + category = TvSettingsCategory.Downloads, + title = context.getString(R.string.concurrent_connections), + summary = context.getString(R.string.concurrent_connections_settings_des), + options = countOptions, + selectedKey = settings.general.concurrentConnections.get().coerceIn(1, 10).toString(), + ), + ) + } + + private fun appItems(): List { + val accountName = DataStoreHelper.getCurrentAccount()?.name + ?: runCatching { DataStoreHelper.getDefaultAccount(context).name }.getOrNull() + ?: context.getString(R.string.default_account) + val items = mutableListOf( + TvSettingItem( + id = ID_ACCOUNT_DISPLAY, + category = TvSettingsCategory.App, + title = context.getString(R.string.account), + summary = "Local profile (read-only)", + valueLabel = accountName, + control = TvSettingControlKind.ReadOnly, + ), + boolItem( + id = ID_SKIP_ACCOUNT, + category = TvSettingsCategory.App, + title = context.getString(R.string.skip_startup_account_select_pref), + pref = settings.security.skipAccountSelection, + ), + boolItem( + id = ID_AUTO_UPDATE, + category = TvSettingsCategory.App, + title = context.getString(R.string.updates_settings), + summary = context.getString(R.string.updates_settings_des), + pref = settings.updates.showAppUpdates, + ), + enumItem( + id = ID_DNS, + category = TvSettingsCategory.App, + title = context.getString(R.string.dns_pref), + summary = context.getString(R.string.dns_pref_summary), + options = zipIntString(R.array.dns_pref_values, R.array.dns_pref), + selectedKey = settings.general.dns.get().toString(), + ), + boolItem( + id = ID_JSDELIVR, + category = TvSettingsCategory.App, + title = context.getString(R.string.jsdelivr_proxy), + summary = context.getString(R.string.jsdelivr_proxy_summary), + pref = settings.general.jsdelivrProxy, + ), + ) + if (BuildConfig.DEBUG) { + items.add( + TvSettingItem( + id = ID_FOCUS_PROBE, + category = TvSettingsCategory.App, + title = context.getString(R.string.compose_tv_debug), + summary = "Phase 2 focus probe (debug builds only)", + control = TvSettingControlKind.Action, + valueLabel = "Open", + ), + ) + } + return items + } + + private fun booleanPref(id: String): PreferenceData? = when (id) { + ID_AUTOPLAY -> settings.player.autoPlayEnabled + ID_SKIP_OP -> settings.player.skipOpEnabled + ID_EPISODE_SYNC -> settings.player.episodeSync + ID_START_PAUSED -> settings.player.startPaused + ID_SPEED -> settings.player.speedEnabled + ID_SHOW_NAME -> settings.player.showName + ID_SHOW_RESOLUTION -> settings.player.showResolution + ID_SHOW_MEDIA_INFO -> settings.player.showMediaInfo + ID_TRAILERS -> settings.ui.trailersEnabled + ID_KITSU -> settings.ui.kitsuPostersEnabled + ID_CAST -> settings.ui.castEnabled + ID_FILLERS -> settings.ui.fillersEnabled + ID_CLOCK -> settings.ui.showClock + ID_METADATA_OVERLAY -> settings.ui.showMetadataOverlay + ID_SKIP_ACCOUNT -> settings.security.skipAccountSelection + ID_AUTO_UPDATE -> settings.updates.showAppUpdates + ID_JSDELIVR -> settings.general.jsdelivrProxy + else -> null + } + + private fun boolItem( + id: String, + category: TvSettingsCategory, + title: String, + summary: String? = null, + pref: PreferenceData, + ): TvSettingItem { + val value = pref.get() + return TvSettingItem( + id = id, + category = category, + title = title, + summary = summary, + valueLabel = if (value) "On" else "Off", + control = TvSettingControlKind.Boolean, + booleanValue = value, + ) + } + + private fun enumItem( + id: String, + category: TvSettingsCategory, + title: String, + summary: String? = null, + options: List, + selectedKey: String, + requiresRestart: Boolean = false, + restartMessage: String? = null, + ): TvSettingItem { + val label = options.firstOrNull { it.key == selectedKey }?.label ?: selectedKey + return TvSettingItem( + id = id, + category = category, + title = title, + summary = summary, + valueLabel = label, + control = TvSettingControlKind.Enum, + options = options, + selectedOptionKey = selectedKey, + requiresRestart = requiresRestart, + restartMessage = restartMessage, + ) + } + + private fun zipIntString(valuesRes: Int, namesRes: Int): List { + val values = context.resources.getIntArray(valuesRes) + val names = context.resources.getStringArray(namesRes) + val n = minOf(values.size, names.size) + return (0 until n).map { i -> + TvSettingOption(key = values[i].toString(), label = names[i]) + } + } + + private fun seekSecondOptions(): List { + // Discrete TV choices instead of a phone SeekBar — same int key written to AppSettings. + val seconds = listOf(5, 10, 15, 20, 25, 30, 45, 60) + return seconds.map { s -> TvSettingOption(key = s.toString(), label = "${s}s") } + } + + private fun nearestSeekKey(current: Int): String { + val options = listOf(5, 10, 15, 20, 25, 30, 45, 60) + val nearest = options.minByOrNull { kotlin.math.abs(it - current) } ?: current + return nearest.toString() + } + + companion object { + const val ID_AUTOPLAY = "playback.autoplay" + const val ID_SKIP_OP = "playback.skip_op" + const val ID_EPISODE_SYNC = "playback.episode_sync" + const val ID_START_PAUSED = "playback.start_paused" + const val ID_SPEED = "playback.speed" + const val ID_SOFTWARE_DECODING = "playback.software_decoding" + const val ID_TV_SEEK_ON = "playback.tv_seek_on" + const val ID_TV_SEEK_OFF = "playback.tv_seek_off" + const val ID_SHOW_NAME = "playback.show_name" + const val ID_SHOW_RESOLUTION = "playback.show_resolution" + const val ID_SHOW_MEDIA_INFO = "playback.show_media_info" + + const val ID_TRAILERS = "appearance.trailers" + const val ID_KITSU = "appearance.kitsu" + const val ID_CAST = "appearance.cast" + const val ID_FILLERS = "appearance.fillers" + const val ID_CLOCK = "appearance.clock" + const val ID_METADATA_OVERLAY = "appearance.metadata_overlay" + const val ID_CONFIRM_EXIT = "appearance.confirm_exit" + + const val ID_LOCALE = "language.locale" + + const val ID_PARALLEL_DOWNLOADS = "downloads.parallel" + const val ID_CONCURRENT_CONNECTIONS = "downloads.concurrent" + + const val ID_ACCOUNT_DISPLAY = "app.account_display" + const val ID_SKIP_ACCOUNT = "app.skip_account" + const val ID_AUTO_UPDATE = "app.auto_update" + const val ID_DNS = "app.dns" + const val ID_JSDELIVR = "app.jsdelivr" + const val ID_FOCUS_PROBE = "app.focus_probe" + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsScreen.kt new file mode 100644 index 00000000000..fb6b9338e58 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsScreen.kt @@ -0,0 +1,300 @@ +package com.lagradost.cloudstream3.tv.settings + +import android.app.Activity +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.ClickableSurfaceDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Surface +import androidx.tv.material3.Text +import com.lagradost.cloudstream3.tv.components.TvConfirmDialog +import com.lagradost.cloudstream3.tv.components.TvEnumDialog +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.components.TvOnResume +import com.lagradost.cloudstream3.tv.model.TvSettingControlKind +import com.lagradost.cloudstream3.tv.model.TvSettingItem +import com.lagradost.cloudstream3.tv.model.TvSettingsAction +import com.lagradost.cloudstream3.tv.model.TvSettingsCatalog +import com.lagradost.cloudstream3.tv.model.TvSettingsUiState + +/** + * Phase 11 — small useful TV Settings over EXISTING [com.lagradost.cloudstream4.AppSettings]. + * Same PreferenceManager store as phone; no parallel prefs / new DataStore keys. + */ +@Composable +fun TvSettingsScreen( + modifier: Modifier = Modifier, + onOpenFocusProbe: () -> Unit = {}, + viewModel: TvSettingsViewModel = viewModel(), +) { + val uiState by viewModel.state.collectAsState() + val context = LocalContext.current + val activity = context as? Activity + + var enumTarget by remember { mutableStateOf(null) } + var pendingRestart by remember { mutableStateOf(null) } + val firstRowFocus = remember { FocusRequester() } + var sideEffectTick by remember { mutableStateOf(0) } + + TvOnResume { viewModel.onAction(TvSettingsAction.Refresh) } + LaunchedEffect(Unit) { viewModel.onAction(TvSettingsAction.Refresh) } + + LaunchedEffect(uiState, sideEffectTick) { + when (val effect = viewModel.consumeSideEffect()) { + TvSettingsViewModel.SideEffect.RecreateActivity -> { + // Same as phone locale path — activity.recreate(); no custom restart system. + activity?.recreate() + } + TvSettingsViewModel.SideEffect.OpenFocusProbe -> onOpenFocusProbe() + null -> Unit + } + } + + Box(modifier = modifier.fillMaxSize()) { + when (val state = uiState) { + TvSettingsUiState.Loading -> { + Text( + text = "Loading settings…", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier + .align(Alignment.Center) + .padding(48.dp), + ) + } + is TvSettingsUiState.Ready -> { + SettingsList( + catalog = state.catalog, + firstRowFocus = firstRowFocus, + onToggle = { item -> + viewModel.onAction(TvSettingsAction.ToggleBoolean(item.id)) + }, + onOpenEnum = { item -> enumTarget = item }, + onAction = { item -> + viewModel.onAction(TvSettingsAction.InvokeAction(item.id)) + sideEffectTick += 1 + }, + ) + LaunchedEffect(state.catalog.sections.firstOrNull()?.items?.firstOrNull()?.id) { + runCatching { firstRowFocus.requestFocus() } + } + } + } + + val target = enumTarget + if (target != null && target.control == TvSettingControlKind.Enum) { + TvEnumDialog( + title = target.title, + options = target.options, + selectedKey = target.selectedOptionKey, + onSelect = { option -> + enumTarget = null + if (target.requiresRestart) { + pendingRestart = PendingRestart(target, option.key) + } else { + viewModel.onAction(TvSettingsAction.SelectEnum(target.id, option.key)) + sideEffectTick += 1 + } + }, + onDismiss = { enumTarget = null }, + ) + } + + val restart = pendingRestart + if (restart != null) { + TvConfirmDialog( + title = "Restart required", + message = restart.item.restartMessage + ?: "This setting is saved now but applies after the activity restarts.", + confirmLabel = "Save & restart", + onConfirm = { + val item = restart.item + val key = restart.optionKey + pendingRestart = null + viewModel.onAction(TvSettingsAction.SelectEnum(item.id, key)) + sideEffectTick += 1 + }, + onDismiss = { pendingRestart = null }, + ) + } + } +} + +private data class PendingRestart( + val item: TvSettingItem, + val optionKey: String, +) + +@Composable +private fun SettingsList( + catalog: TvSettingsCatalog, + firstRowFocus: FocusRequester, + onToggle: (TvSettingItem) -> Unit, + onOpenEnum: (TvSettingItem) -> Unit, + onAction: (TvSettingItem) -> Unit, +) { + val flatRows = remember(catalog) { + buildList { + catalog.accountDisplayName?.let { name -> + add(ListRow.Header("Settings · $name")) + } ?: add(ListRow.Header("Settings")) + catalog.sections.forEach { section -> + add(ListRow.Header(section.category.label)) + section.items.forEach { add(ListRow.Item(it)) } + } + } + } + val firstItemIndex = remember(flatRows) { + flatRows.indexOfFirst { it is ListRow.Item } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 32.dp, vertical = 24.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + itemsIndexed( + flatRows, + key = { index, row -> + when (row) { + is ListRow.Header -> "h-$index-${row.title}" + is ListRow.Item -> row.item.id + } + }, + ) { index, row -> + when (row) { + is ListRow.Header -> { + Text( + text = row.title, + style = if (row.title.startsWith("Settings")) { + MaterialTheme.typography.headlineMedium + } else { + MaterialTheme.typography.titleLarge + }, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp, start = 4.dp), + ) + } + is ListRow.Item -> { + SettingRow( + item = row.item, + onClick = { + when (row.item.control) { + TvSettingControlKind.Boolean -> onToggle(row.item) + TvSettingControlKind.Enum -> onOpenEnum(row.item) + TvSettingControlKind.Action -> onAction(row.item) + TvSettingControlKind.ReadOnly -> Unit + } + }, + modifier = if (index == firstItemIndex) { + Modifier.focusRequester(firstRowFocus) + } else { + Modifier + }, + ) + } + } + } + } +} + +private sealed interface ListRow { + data class Header(val title: String) : ListRow + data class Item(val item: TvSettingItem) : ListRow +} + +@Composable +private fun SettingRow( + item: TvSettingItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val enabled = item.control != TvSettingControlKind.ReadOnly + Surface( + onClick = onClick, + enabled = enabled, + modifier = modifier.fillMaxWidth(), + scale = ClickableSurfaceDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + colors = ClickableSurfaceDefaults.colors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + focusedContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + contentColor = MaterialTheme.colorScheme.onSurface, + focusedContentColor = MaterialTheme.colorScheme.onSurface, + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = item.title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val sub = buildString { + item.summary?.let { append(it) } + if (item.requiresRestart) { + if (isNotEmpty()) append(" · ") + append("Restart required") + } + } + if (sub.isNotBlank()) { + Text( + text = sub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + text = when (item.control) { + TvSettingControlKind.Boolean -> if (item.booleanValue) "On" else "Off" + TvSettingControlKind.Enum -> item.valueLabel.orEmpty() + TvSettingControlKind.Action -> item.valueLabel ?: "Open" + TvSettingControlKind.ReadOnly -> item.valueLabel.orEmpty() + }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsViewModel.kt new file mode 100644 index 00000000000..f045824b918 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsViewModel.kt @@ -0,0 +1,75 @@ +package com.lagradost.cloudstream3.tv.settings + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.tv.model.TvSettingsAction +import com.lagradost.cloudstream3.tv.model.TvSettingsUiState +import com.lagradost.cloudstream4.AppSettings +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Settings state over EXISTING [AppSettings] — no second prefs store. + * Snapshot refresh on enter/resume/write only (no preference polling spam). + */ +class TvSettingsViewModel( + application: Application, +) : AndroidViewModel(application), + StateContainer by DefaultStateContainer(TvSettingsUiState.Loading), + ActionHandler { + + private val settings = AppSettings(application.applicationContext) + private val adapter = TvSettingsAdapter(application.applicationContext, settings) + + /** One-shot side effect for the screen (recreate / open focus probe). */ + var pendingSideEffect: SideEffect? = null + private set + + fun consumeSideEffect(): SideEffect? { + val e = pendingSideEffect + pendingSideEffect = null + return e + } + + sealed interface SideEffect { + data object RecreateActivity : SideEffect + data object OpenFocusProbe : SideEffect + } + + override fun onAction(action: TvSettingsAction) { + when (action) { + TvSettingsAction.Refresh -> refresh() + is TvSettingsAction.ToggleBoolean -> { + adapter.toggleBoolean(action.id) + refresh() + } + is TvSettingsAction.SelectEnum -> { + when (val result = adapter.selectEnum(action.id, action.optionKey)) { + TvSettingsAdapter.ApplyResult.NeedsRecreate -> { + pendingSideEffect = SideEffect.RecreateActivity + refresh() + } + TvSettingsAdapter.ApplyResult.Applied -> refresh() + TvSettingsAdapter.ApplyResult.Unknown -> Unit + } + } + is TvSettingsAction.InvokeAction -> { + if (action.id == TvSettingsAdapter.ID_FOCUS_PROBE) { + pendingSideEffect = SideEffect.OpenFocusProbe + } + } + } + } + + private fun refresh() { + viewModelScope.launch { + val catalog = withContext(Dispatchers.IO) { adapter.buildCatalog() } + updateState { TvSettingsUiState.Ready(catalog) } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistScreen.kt new file mode 100644 index 00000000000..c5bd6df50a2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistScreen.kt @@ -0,0 +1,295 @@ +package com.lagradost.cloudstream3.tv.watchlist + +import androidx.compose.foundation.focusGroup +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.runtime.snapshots.SnapshotStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.focusRestorer +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.tv.material3.Button +import androidx.tv.material3.ButtonDefaults +import androidx.tv.material3.MaterialTheme +import androidx.tv.material3.Text +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.components.TvMediaCard +import com.lagradost.cloudstream3.tv.components.TvOnResume +import com.lagradost.cloudstream3.tv.model.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvWatchlistAction +import com.lagradost.cloudstream3.tv.model.TvWatchlistCatalog +import com.lagradost.cloudstream3.tv.model.TvWatchlistItem +import com.lagradost.cloudstream3.tv.model.TvWatchlistSection +import com.lagradost.cloudstream3.tv.model.TvAvailabilityClassifier +import com.lagradost.cloudstream3.tv.model.TvAvailabilityKind +import com.lagradost.cloudstream3.tv.model.TvWatchlistUiState + +/** + * Hoisted Watchlist focus — survives Details overlay in the navigation shell. + */ +class TvWatchlistFocusState( + val sectionFocusIndices: SnapshotStateMap = mutableStateMapOf(), +) { + var lastFocusedSectionId: String? by mutableStateOf(null) + var initialFocusDone: Boolean by mutableStateOf(false) + + fun indexFor(sectionId: String): Int = sectionFocusIndices[sectionId] ?: 0 + + fun update(sectionId: String, index: Int) { + sectionFocusIndices[sectionId] = index + lastFocusedSectionId = sectionId + } + + fun pruneTo(catalog: TvWatchlistCatalog) { + val alive = catalog.sections.associate { it.id to it.items.size } + sectionFocusIndices.keys.filter { it !in alive }.forEach { sectionFocusIndices.remove(it) } + alive.forEach { (id, size) -> + if (size <= 0) sectionFocusIndices.remove(id) + else { + val idx = sectionFocusIndices[id] ?: return@forEach + if (idx > size - 1) sectionFocusIndices[id] = size - 1 + } + } + if (lastFocusedSectionId != null && lastFocusedSectionId !in alive) { + lastFocusedSectionId = null + } + } +} + +@Composable +fun rememberTvWatchlistFocusState(): TvWatchlistFocusState = remember { TvWatchlistFocusState() } + +@Composable +fun TvWatchlistScreen( + focusState: TvWatchlistFocusState, + modifier: Modifier = Modifier, + onOpenDetails: (TvContentRef) -> Unit = {}, + viewModel: TvWatchlistViewModel = viewModel(), +) { + val uiState by viewModel.state.collectAsState() + + // Enter (incl. return from Details) + Activity resume — no polling. + LaunchedEffect(Unit) { viewModel.onAction(TvWatchlistAction.Refresh) } + TvOnResume { viewModel.onAction(TvWatchlistAction.Refresh) } + + when (val state = uiState) { + is TvWatchlistUiState.Loading -> TvWatchlistLoadingPane(modifier) + is TvWatchlistUiState.Content -> TvWatchlistContentPane( + catalog = state.catalog, + focusState = focusState, + onOpenItem = { item -> onOpenDetails(item.toContentRef()) }, + modifier = modifier, + ) + is TvWatchlistUiState.Empty -> TvWatchlistStatusPane( + title = TvAvailabilityKind.Unavailable.label, + body = buildString { + append(state.message) + state.accountName?.let { append("\nProfile: $it") } + append("\n\nViewing only — add or edit watch status from the phone/tablet UI.") + }, + primaryLabel = "Retry", + onPrimary = { viewModel.onAction(TvWatchlistAction.Retry) }, + modifier = modifier, + ) + is TvWatchlistUiState.Error -> { + val status = TvAvailabilityClassifier.fromWatchlistFailure(state.message) + TvWatchlistStatusPane( + title = status.title, + body = state.message + "\n\nRetry. No silent swap to demo Library.", + primaryLabel = "Retry", + onPrimary = { viewModel.onAction(TvWatchlistAction.Retry) }, + modifier = modifier, + ) + } + } +} + +@Composable +private fun TvWatchlistContentPane( + catalog: TvWatchlistCatalog, + focusState: TvWatchlistFocusState, + onOpenItem: (TvWatchlistItem) -> Unit, + modifier: Modifier = Modifier, +) { + var pendingRestoreSectionId by remember { mutableStateOf(null) } + val sections = remember(catalog.sections) { catalog.sections.filter { it.items.isNotEmpty() } } + + LaunchedEffect(catalog) { + focusState.pruneTo(catalog) + } + + LaunchedEffect(Unit) { + if (!focusState.initialFocusDone) { + focusState.initialFocusDone = true + pendingRestoreSectionId = sections.firstOrNull()?.id + } else { + pendingRestoreSectionId = focusState.lastFocusedSectionId ?: sections.firstOrNull()?.id + } + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), + ) { + item(key = "header") { + Text( + text = "Watchlist", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + val subtitle = buildList { + add("Local Library") + catalog.accountName?.let { add("Profile: $it") } + add("Viewing only") + }.joinToString(" · ") + Text( + text = subtitle, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + itemsIndexed(sections, key = { _, section -> section.id }) { _, section -> + val shouldRestore = pendingRestoreSectionId == section.id + TvWatchlistSectionRail( + section = section, + lastFocusedIndex = focusState.indexFor(section.id), + onFocusedIndexChanged = { index -> focusState.update(section.id, index) }, + onItemClick = onOpenItem, + restoreFocus = shouldRestore, + onRestoreConsumed = { + if (pendingRestoreSectionId == section.id) { + pendingRestoreSectionId = null + } + }, + ) + } + } +} + +@Composable +private fun TvWatchlistSectionRail( + section: TvWatchlistSection, + lastFocusedIndex: Int, + onFocusedIndexChanged: (Int) -> Unit, + onItemClick: (TvWatchlistItem) -> Unit, + restoreFocus: Boolean, + onRestoreConsumed: () -> Unit, +) { + if (section.items.isEmpty()) return + val safeIndex = lastFocusedIndex.coerceIn(0, section.items.lastIndex) + val focusRequesters = remember(section.id, section.items.size) { + List(section.items.size) { FocusRequester() } + } + val listState = rememberLazyListState() + + LaunchedEffect(restoreFocus, section.id, safeIndex, section.items.size) { + if (restoreFocus && focusRequesters.isNotEmpty()) { + listState.scrollToItem(safeIndex) + runCatching { focusRequesters[safeIndex].requestFocus() } + onRestoreConsumed() + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = section.title, + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(horizontal = 8.dp), + ) + LazyRow( + state = listState, + contentPadding = PaddingValues(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier + .fillMaxWidth() + .focusRestorer(focusRequesters.getOrNull(safeIndex) ?: FocusRequester.Default) + .focusGroup(), + ) { + itemsIndexed(section.items, key = { _, item -> item.id }) { index, item -> + TvMediaCard( + item = item.toMediaItem(), + onClick = { onItemClick(item) }, + onFocused = { onFocusedIndexChanged(index) }, + modifier = Modifier.focusRequester(focusRequesters[index]), + ) + } + } + } +} + +@Composable +private fun TvWatchlistLoadingPane(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Loading Library…", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TvWatchlistStatusPane( + title: String, + body: String, + primaryLabel: String, + onPrimary: () -> Unit, + modifier: Modifier = Modifier, +) { + val retryFocus = remember { FocusRequester() } + LaunchedEffect(title) { + runCatching { retryFocus.requestFocus() } + } + Column( + modifier = modifier + .fillMaxSize() + .padding(48.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button( + onClick = onPrimary, + modifier = Modifier.focusRequester(retryFocus), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(primaryLabel) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistViewModel.kt new file mode 100644 index 00000000000..e814ddd6845 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistViewModel.kt @@ -0,0 +1,63 @@ +package com.lagradost.cloudstream3.tv.watchlist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lagradost.cloudstream3.mvvm.logError +import com.lagradost.cloudstream3.tv.data.TvWatchlistRepository +import com.lagradost.cloudstream3.tv.model.TvWatchlistAction +import com.lagradost.cloudstream3.tv.model.TvWatchlistUiState +import com.lagradost.cloudstream4.compose.ActionHandler +import com.lagradost.cloudstream4.compose.DefaultStateContainer +import com.lagradost.cloudstream4.compose.StateContainer +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Lifecycle-aware Watchlist / Library state (Local list only, read-only). + * Loads on enter / resume / Retry — never polls, never writes DataStore. + */ +class TvWatchlistViewModel( + private val repository: TvWatchlistRepository = TvWatchlistRepository(), +) : ViewModel(), + StateContainer by DefaultStateContainer(TvWatchlistUiState.Loading), + ActionHandler { + + private var loadJob: Job? = null + + override fun onAction(action: TvWatchlistAction) { + when (action) { + TvWatchlistAction.Retry, + TvWatchlistAction.Refresh, + -> load() + } + } + + private fun load() { + loadJob?.cancel() + loadJob = viewModelScope.launch { + updateState { TvWatchlistUiState.Loading } + val result = try { + withContext(Dispatchers.IO) { repository.loadWatchlist() } + } catch (t: Throwable) { + logError(t) + TvWatchlistRepository.LoadResult.Failure( + t.message ?: "Unexpected error reading Library", + ) + } + updateState { + when (result) { + is TvWatchlistRepository.LoadResult.Success -> + TvWatchlistUiState.Content(result.catalog) + + is TvWatchlistRepository.LoadResult.Empty -> + TvWatchlistUiState.Empty(accountName = result.catalog.accountName) + + is TvWatchlistRepository.LoadResult.Failure -> + TvWatchlistUiState.Error(result.message) + } + } + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt index c04215594e1..c51ca84162e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt @@ -1,5 +1,6 @@ package com.lagradost.cloudstream3.ui.settings +import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View @@ -11,6 +12,7 @@ import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.tv.TvComposeProbeActivity import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R @@ -102,6 +104,14 @@ class SettingsUpdates : BasePreferenceFragmentCompat() { return@setOnPreferenceClickListener true } + getPref(R.string.compose_tv_debug_key)?.let { pref -> + pref.isVisible = BuildConfig.DEBUG + pref.setOnPreferenceClickListener { + startActivity(Intent(requireContext(), TvComposeProbeActivity::class.java)) + true + } + } + getPref(R.string.redo_setup_key)?.setOnPreferenceClickListener { findNavController().navigate(R.id.navigation_setup_language) return@setOnPreferenceClickListener true diff --git a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt index 3c90e8e3684..9792bfb74a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdatesScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.BuildConfig +import com.lagradost.cloudstream3.tv.TvComposeProbeActivity import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast @@ -35,6 +36,7 @@ import com.mihon.presentation.settings.Preference import com.mihon.presentation.settings.SearchableSettings import com.mihon.presentation.settings.collectAsState import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList object SettingsUpdatesScreen : SearchableSettings { @Composable @@ -207,22 +209,40 @@ object SettingsUpdatesScreen : SearchableSettings { ), Preference.PreferenceGroup( title = stringResource(R.string.pref_category_actions), - preferenceItems = persistentListOf( - Preference.PreferenceItem.TextPreference( - title = stringResource(R.string.show_log_cat), - icon = painterResource(R.drawable.article_24px), - onClick = { - showDialog = true - } - ), - Preference.PreferenceItem.TextPreference( - title = stringResource(R.string.redo_setup_process), - icon = painterResource(R.drawable.construction_24px), - onClick = { - activity?.navigate(R.id.navigation_setup_language) - } - ), - ) + preferenceItems = buildList { + add( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.show_log_cat), + icon = painterResource(R.drawable.article_24px), + onClick = { + showDialog = true + } + ) + ) + if (BuildConfig.DEBUG) { + add( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.compose_tv_debug), + subtitle = stringResource(R.string.compose_tv_debug_summary), + icon = painterResource(R.drawable.ic_baseline_tv_24), + onClick = { + activity?.startActivity( + Intent(activity, TvComposeProbeActivity::class.java) + ) + } + ) + ) + } + add( + Preference.PreferenceItem.TextPreference( + title = stringResource(R.string.redo_setup_process), + icon = painterResource(R.drawable.construction_24px), + onClick = { + activity?.navigate(R.id.navigation_setup_language) + } + ) + ) + }.toPersistentList() ) ) } diff --git a/app/src/main/res/layout/activity_tv_compose_probe.xml b/app/src/main/res/layout/activity_tv_compose_probe.xml new file mode 100644 index 00000000000..716a779dfbf --- /dev/null +++ b/app/src/main/res/layout/activity_tv_compose_probe.xml @@ -0,0 +1,17 @@ + + + + + + + + + diff --git a/app/src/main/res/values/donottranslate-strings.xml b/app/src/main/res/values/donottranslate-strings.xml index a1334f0b192..2ef61dcefba 100644 --- a/app/src/main/res/values/donottranslate-strings.xml +++ b/app/src/main/res/values/donottranslate-strings.xml @@ -63,6 +63,7 @@ episode_sync_enabled_key log_enabled_key show_logcat_key + compose_tv_debug_key bottom_title_key poster_ui_key overscan_key diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 66eb3533c4c..09663f65a61 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -140,6 +140,8 @@ No Plot Found No Description Found Show Logcat 🐈 + Compose TV (debug) + Open the Compose for TV shell (mock data only) Logcat 🐈 Log Picture-in-picture diff --git a/app/src/main/res/xml/settings_updates.xml b/app/src/main/res/xml/settings_updates.xml index 77ebae47435..03d502e7979 100644 --- a/app/src/main/res/xml/settings_updates.xml +++ b/app/src/main/res/xml/settings_updates.xml @@ -78,6 +78,11 @@ android:icon="@drawable/baseline_description_24" android:key="@string/show_logcat_key" android:title="@string/show_log_cat" /> +