From a73a46e87d3e2028dacfc3007aaab66368adc6c1 Mon Sep 17 00:00:00 2001 From: sika200581 <180838598+sika200581@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:24:13 +0000 Subject: [PATCH 01/11] Add Compose for TV Phase 1 probe (tv-material) Introduce an isolated TvComposeProbeActivity with TvTheme and a small focusable card/button screen using androidx.tv:tv-material:1.1.0 only. Register the activity without launcher filters so phone and legacy TV startup stay unchanged; document adb launch for debug builds. --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 12 ++ .../cloudstream3/tv/TvComposeProbeActivity.kt | 35 ++++ .../cloudstream3/tv/TvProbeScreen.kt | 154 ++++++++++++++++++ .../com/lagradost/cloudstream3/tv/TvTheme.kt | 41 +++++ docs/TV_COMPOSE_PROBE.md | 38 +++++ gradle/libs.versions.toml | 2 + 7 files changed, 283 insertions(+) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/TvProbeScreen.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt create mode 100644 docs/TV_COMPOSE_PROBE.md 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..3053a5f193d 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..4896b4fe5c7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -0,0 +1,35 @@ +package com.lagradost.cloudstream3.tv + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge + +/** + * Isolated Compose-for-TV probe activity (Phase 1). + * + * 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 + * ``` + * + * Launch (release / no debug suffix): + * ``` + * adb shell am start -n com.lagradost.cloudstream3/.tv.TvComposeProbeActivity + * ``` + * + * Note: activity is `android:exported="false"`; adb can still start it on debuggable builds. + */ +class TvComposeProbeActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + setContent { + TvTheme { + TvProbeScreen() + } + } + } +} 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..4e0624ae686 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt @@ -0,0 +1,41 @@ +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 + +/** + * Isolated cinematic dark high-contrast theme for the Compose-for-TV probe. + * Uses only [androidx.tv.material3] — never phone Material3. + */ +private val TvProbeDarkColorScheme = 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(0xFF0A0A0C), + onBackground = Color(0xFFF5F5F7), + surface = Color(0xFF141418), + onSurface = Color(0xFFF5F5F7), + surfaceVariant = Color(0xFF2A2A32), + onSurfaceVariant = Color(0xFFD0D0D8), + border = Color(0xFF5A5A68), + borderVariant = Color(0xFF3A3A44), + error = Color(0xFFFF8A80), + onError = Color(0xFF3B0000), +) + +@Composable +fun TvTheme(content: @Composable () -> Unit) { + MaterialTheme( + colorScheme = TvProbeDarkColorScheme, + content = content, + ) +} diff --git a/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md new file mode 100644 index 00000000000..83a5800e379 --- /dev/null +++ b/docs/TV_COMPOSE_PROBE.md @@ -0,0 +1,38 @@ +# Compose for TV — Phase 1 probe + +Isolated smoke test for `androidx.tv:tv-material` focus / theme / D-pad behavior. + +## Scope + +- Activity: `com.lagradost.cloudstream3.tv.TvComposeProbeActivity` +- Theme: `TvTheme` (`androidx.tv.material3.MaterialTheme` only) +- Screen: title + focusable cards/buttons (no Home, catalog, player, or API bridge) + +Phone UI and legacy XML TV remain the default launch path (`AccountSelectActivity` MAIN + LEANBACK_LAUNCHER). + +## How to open + +Build a debug APK, install, then: + +```bash +# stableDebug (applicationId com.lagradost.cloudstream3.debug) +adb shell am start -n com.lagradost.cloudstream3.debug/com.lagradost.cloudstream3.tv.TvComposeProbeActivity +``` + +Release / no debug suffix: + +```bash +adb shell am start -n com.lagradost.cloudstream3/.tv.TvComposeProbeActivity +``` + +The activity is not exported as a launcher; default startup is unchanged. + +## Validate + +```bash +./gradlew :app:compileStableDebugKotlin +# or +./gradlew :app:assembleStableDebug +``` + +Ensure `app/src/main/java/com/lagradost/cloudstream3/tv/` has **no** `androidx.compose.material3` imports. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 4652cf938c1..93beef438a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -52,6 +52,7 @@ safefile = "0.0.8" shimmer = "0.5.0" torrentserver = "7861970" tvprovider = "1.1.0" +tvMaterial = "1.1.0" video = "1.0.0" workRuntimeKtx = "2.11.2" zipline = "1.27.0" @@ -139,6 +140,7 @@ safefile = { module = "com.github.LagradOst:SafeFile", version.ref = "safefile" shimmer = { module = "com.facebook.shimmer:shimmer", version.ref = "shimmer" } torrentserver = { module = "com.github.recloudstream:torrentserver", version.ref = "torrentserver" } tvprovider = { module = "androidx.tvprovider:tvprovider", version.ref = "tvprovider" } +tv-material = { module = "androidx.tv:tv-material", version.ref = "tvMaterial" } video = { module = "com.google.android.mediahome:video", version.ref = "video" } work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workRuntimeKtx" } zipline = { module = "app.cash.zipline:zipline-android", version.ref = "zipline" } From 340f0f16dbb07d7d6183820642f36b7f63c2139b Mon Sep 17 00:00:00 2001 From: sika200581 <180838598+sika200581@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:33:34 +0000 Subject: [PATCH 02/11] Add Compose for TV Phase 2 mock shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build a structural TV navigation shell with mock Home (hero + rails), reusable focus scale/glow cards, and DEBUG-only settings entry — still isolated from APIRepository, player, and legacy TV XML. --- app/src/main/AndroidManifest.xml | 4 +- .../cloudstream3/tv/TvComposeProbeActivity.kt | 12 +- .../com/lagradost/cloudstream3/tv/TvTheme.kt | 15 +- .../tv/components/TvFocusScale.kt | 49 ++++++ .../cloudstream3/tv/components/TvMediaCard.kt | 108 ++++++++++++ .../cloudstream3/tv/home/TvContentRail.kt | 81 +++++++++ .../cloudstream3/tv/home/TvHeroSection.kt | 147 ++++++++++++++++ .../cloudstream3/tv/home/TvHomeScreen.kt | 87 ++++++++++ .../cloudstream3/tv/model/TvMockModels.kt | 127 ++++++++++++++ .../tv/navigation/TvNavigationShell.kt | 158 ++++++++++++++++++ .../ui/settings/SettingsUpdates.kt | 10 ++ .../ui/settings/SettingsUpdatesScreen.kt | 52 ++++-- .../res/values/donottranslate-strings.xml | 1 + app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/settings_updates.xml | 5 + docs/TV_COMPOSE_PROBE.md | 36 ++-- 16 files changed, 845 insertions(+), 49 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/components/TvFocusScale.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3053a5f193d..ede2076b904 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -272,13 +272,13 @@ 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 index 4896b4fe5c7..dceed6c2b84 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -4,9 +4,10 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import com.lagradost.cloudstream3.tv.navigation.TvNavigationShell /** - * Isolated Compose-for-TV probe activity (Phase 1). + * Compose-for-TV host activity (Phase 2 shell with mock Home). * * Not registered as MAIN / LEANBACK_LAUNCHER — default phone + legacy TV startup unchanged. * @@ -15,12 +16,7 @@ import androidx.activity.enableEdgeToEdge * adb shell am start -n com.lagradost.cloudstream3.debug/com.lagradost.cloudstream3.tv.TvComposeProbeActivity * ``` * - * Launch (release / no debug suffix): - * ``` - * adb shell am start -n com.lagradost.cloudstream3/.tv.TvComposeProbeActivity - * ``` - * - * Note: activity is `android:exported="false"`; adb can still start it on debuggable builds. + * Also available from Settings → Updates → Actions → "Compose TV (debug)" when BuildConfig.DEBUG. */ class TvComposeProbeActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -28,7 +24,7 @@ class TvComposeProbeActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { TvTheme { - TvProbeScreen() + TvNavigationShell() } } } diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt index 4e0624ae686..c6463830eae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvTheme.kt @@ -6,10 +6,10 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.darkColorScheme /** - * Isolated cinematic dark high-contrast theme for the Compose-for-TV probe. + * Cinematic dark high-contrast theme for Compose TV. * Uses only [androidx.tv.material3] — never phone Material3. */ -private val TvProbeDarkColorScheme = darkColorScheme( +private val TvCinematicDarkColorScheme = darkColorScheme( primary = Color(0xFFFFB74D), onPrimary = Color(0xFF1A1200), primaryContainer = Color(0xFF5C3B00), @@ -20,22 +20,23 @@ private val TvProbeDarkColorScheme = darkColorScheme( onSecondaryContainer = Color(0xFFB2DFDB), tertiary = Color(0xFFCE93D8), onTertiary = Color(0xFF2A0030), - background = Color(0xFF0A0A0C), + background = Color(0xFF07070A), onBackground = Color(0xFFF5F5F7), - surface = Color(0xFF141418), + surface = Color(0xFF121218), onSurface = Color(0xFFF5F5F7), - surfaceVariant = Color(0xFF2A2A32), - onSurfaceVariant = Color(0xFFD0D0D8), + 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 = TvProbeDarkColorScheme, + colorScheme = TvCinematicDarkColorScheme, content = content, ) } 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/TvMediaCard.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt new file mode 100644 index 00000000000..bcc674335dd --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt @@ -0,0 +1,108 @@ +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.layout.ContentScale +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 com.lagradost.cloudstream3.tv.model.TvMockMediaItem + +@Composable +fun TvMediaCard( + item: TvMockMediaItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onFocused: (() -> Unit)? = null, +) { + Column( + modifier = modifier.width(148.dp), + ) { + Card( + onClick = onClick, + 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 = item.posterUrl, + contentDescription = item.title, + contentScale = ContentScale.Crop, + 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)), + ), + ), + ) + 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), + ) + } + } + } + } + Spacer(Modifier.height(10.dp)) + Text( + text = item.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.subtitle.isNotBlank()) { + Text( + text = item.subtitle, + style = MaterialTheme.typography.bodySmall, + color = 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/home/TvContentRail.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt new file mode 100644 index 00000000000..4f59b503b63 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt @@ -0,0 +1,81 @@ +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.TvMockRail + +/** + * Horizontal content rail with per-rail focus memory. + * [lastFocusedIndex] is owned by [TvHomeFocusState] so leaving/returning Home restores focus. + */ +@Composable +fun TvContentRail( + rail: TvMockRail, + lastFocusedIndex: Int, + onFocusedIndexChanged: (Int) -> Unit, + modifier: Modifier = Modifier, + restoreFocus: Boolean = false, + onRestoreConsumed: () -> Unit = {}, +) { + val safeIndex = lastFocusedIndex.coerceIn(0, (rail.items.size - 1).coerceAtLeast(0)) + val focusRequesters = remember(rail.id, rail.items.size) { + List(rail.items.size) { FocusRequester() } + } + val listState = rememberLazyListState() + + LaunchedEffect(restoreFocus, rail.id, safeIndex) { + if (restoreFocus && focusRequesters.isNotEmpty()) { + listState.scrollToItem(safeIndex) + runCatching { focusRequesters[safeIndex].requestFocus() } + onRestoreConsumed() + } + } + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = 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 = {}, + 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..ebe5203e394 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt @@ -0,0 +1,147 @@ +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.layout.ContentScale +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 com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.model.TvMockMediaItem + +@Composable +fun TvHeroSection( + hero: TvMockMediaItem, + watchFocusRequester: FocusRequester, + modifier: Modifier = Modifier, + onWatchNow: () -> Unit = {}, + onDetails: () -> Unit = {}, +) { + Box( + modifier = modifier + .fillMaxWidth() + .height(320.dp), + ) { + AsyncImage( + model = hero.backdropUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + 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 = "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, + ) + 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("Watch Now") + } + Button( + onClick = onDetails, + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text("Details") + } + } + } + } +} + +private fun buildMetadataLine(hero: TvMockMediaItem): String { + val parts = buildList { + hero.year?.let { add(it.toString()) } + hero.rating?.let { add("★ $it") } + hero.runtime?.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..9b9ca44b1d7 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt @@ -0,0 +1,87 @@ +package com.lagradost.cloudstream3.tv.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +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.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.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.unit.dp +import com.lagradost.cloudstream3.tv.model.TvMockCatalog +import com.lagradost.cloudstream3.tv.model.TvMockHomeState + +/** + * Hoisted Home focus memory — survives destination switches in [TvNavigationShell]. + */ +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 + } +} + +@Composable +fun rememberTvHomeFocusState(): TvHomeFocusState = remember { TvHomeFocusState() } + +@Composable +fun TvHomeScreen( + focusState: TvHomeFocusState, + modifier: Modifier = Modifier, + state: TvMockHomeState = TvMockCatalog.home, +) { + val watchFocusRequester = remember { FocusRequester() } + // One-shot rail id to restore when re-entering Home after another destination. + var pendingRestoreRailId by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + if (!focusState.initialHeroFocusDone) { + runCatching { watchFocusRequester.requestFocus() } + focusState.initialHeroFocusDone = true + } else { + pendingRestoreRailId = focusState.lastFocusedRailId + } + } + + LazyColumn( + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(bottom = 48.dp), + verticalArrangement = Arrangement.spacedBy(28.dp), + ) { + item(key = "hero") { + TvHeroSection( + hero = state.hero, + watchFocusRequester = watchFocusRequester, + ) + } + itemsIndexed(state.rails, 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) }, + restoreFocus = shouldRestore, + onRestoreConsumed = { + if (pendingRestoreRailId == rail.id) { + pendingRestoreRailId = null + } + }, + ) + } + } +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt new file mode 100644 index 00000000000..94ed9714e36 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt @@ -0,0 +1,127 @@ +package com.lagradost.cloudstream3.tv.model + +/** + * Minimal immutable mock models for Compose TV Phase 2 UI only. + * Not replacements for SearchResponse / LoadResponse. + */ +data class TvMockMediaItem( + val id: String, + val title: String, + val subtitle: String = "", + val posterUrl: String, + 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, +) + +data class TvMockRail( + val id: String, + val title: String, + val items: List, +) + +data class TvMockHomeState( + val hero: TvMockMediaItem, + val rails: List, +) + +enum class TvDestination(val label: String) { + Home("Home"), + Search("Search"), + Watchlist("Watchlist"), + Settings("Settings"), +} + +object TvMockCatalog { + // Public sample images (picsum) — no binaries committed. + private const val P = "https://picsum.photos/seed" + + val home: TvMockHomeState = TvMockHomeState( + hero = TvMockMediaItem( + id = "hero-nebula", + title = "Nebula Drift", + subtitle = "Original · Sci-Fi", + 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. Cinematic mock hero for Compose TV Phase 2.", + ), + rails = listOf( + TvMockRail( + id = "continue", + title = "Continue Watching", + 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), + ), + ), + TvMockRail( + id = "trending", + title = "Trending", + 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"), + ), + ), + TvMockRail( + id = "movies", + title = "Movies", + 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"), + ), + ), + TvMockRail( + id = "anime", + title = "Anime", + 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), + ), + ), + ), + ) + + private fun item( + id: String, + title: String, + subtitle: String, + seed: String, + year: Int? = null, + rating: String? = null, + runtime: String? = null, + progress: Float? = null, + ) = TvMockMediaItem( + 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, + ) +} 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..e8e62f3d155 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -0,0 +1,158 @@ +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 androidx.tv.material3.WideButton +import androidx.tv.material3.WideButtonDefaults +import com.lagradost.cloudstream3.R +import com.lagradost.cloudstream3.tv.TvProbeScreen +import com.lagradost.cloudstream3.tv.components.TvFocusScale +import com.lagradost.cloudstream3.tv.home.TvHomeScreen +import com.lagradost.cloudstream3.tv.home.rememberTvHomeFocusState +import com.lagradost.cloudstream3.tv.model.TvDestination + +/** + * Structural Compose TV shell: left nav (compact → expands on focus) + destination content. + * Destinations switched via Compose state — no extra navigation library. + */ +@Composable +fun TvNavigationShell( + modifier: Modifier = Modifier, +) { + var destination by rememberSaveable { mutableStateOf(TvDestination.Home.name) } + val selected = runCatching { TvDestination.valueOf(destination) }.getOrDefault(TvDestination.Home) + val homeFocusState = rememberTvHomeFocusState() + var showFocusProbe by rememberSaveable { mutableStateOf(false) } + + 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, + onClick = { + 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 (selected) { + TvDestination.Home -> TvHomeScreen(focusState = homeFocusState) + TvDestination.Search -> TvPlaceholderPane( + title = "Search", + body = "Phase 2 placeholder — search UI and providers land in a later phase.", + ) + TvDestination.Watchlist -> TvPlaceholderPane( + title = "Watchlist", + body = "Phase 2 placeholder — no history / library wiring yet.", + ) + TvDestination.Settings -> { + if (showFocusProbe) { + TvProbeScreen() + } else { + TvPlaceholderPane( + title = "Settings", + body = "Compose TV settings shell (mock). Release launcher unchanged.", + actionLabel = "Open focus probe (canary)", + onAction = { showFocusProbe = true }, + ) + } + } + } + } + } +} + +@Composable +private fun TvPlaceholderPane( + title: String, + body: String, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, +) { + 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, + ) + if (actionLabel != null && onAction != null) { + WideButton( + onClick = onAction, + scale = WideButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text(actionLabel) + } + } + } +} 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/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" /> + Date: Tue, 15 Sep 2026 11:46:24 +0000 Subject: [PATCH 03/11] Add Compose for TV Phase 3 read-only Home catalog bridge Wire APIRepository.getMainPage through TvHomeRepository into immutable TV models and MVI StateContainer UI states, with explicit mock fallback and Continue Watching kept demo-only (no history/DataStore writes). --- .../cloudstream3/tv/TvComposeProbeActivity.kt | 2 +- .../cloudstream3/tv/components/TvMediaCard.kt | 33 ++- .../cloudstream3/tv/data/TvHomeRepository.kt | 189 ++++++++++++++++++ .../cloudstream3/tv/data/TvMediaMapper.kt | 103 ++++++++++ .../cloudstream3/tv/home/TvContentRail.kt | 15 +- .../cloudstream3/tv/home/TvHeroSection.kt | 56 ++++-- .../cloudstream3/tv/home/TvHomeScreen.kt | 183 ++++++++++++++++- .../cloudstream3/tv/home/TvHomeViewModel.kt | 71 +++++++ .../cloudstream3/tv/model/TvHomeModels.kt | 82 ++++++++ .../cloudstream3/tv/model/TvMockCatalog.kt | 107 ++++++++++ .../cloudstream3/tv/model/TvMockModels.kt | 127 ------------ .../tv/navigation/TvNavigationShell.kt | 4 +- docs/TV_COMPOSE_PROBE.md | 33 ++- 13 files changed, 836 insertions(+), 169 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvMediaMapper.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockCatalog.kt delete mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt index dceed6c2b84..173f1b5f6fe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -7,7 +7,7 @@ import androidx.activity.enableEdgeToEdge import com.lagradost.cloudstream3.tv.navigation.TvNavigationShell /** - * Compose-for-TV host activity (Phase 2 shell with mock Home). + * Compose-for-TV host activity (Phase 3: read-only Home catalog bridge). * * Not registered as MAIN / LEANBACK_LAUNCHER — default phone + legacy TV startup unchanged. * 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 index bcc674335dd..fd60ad5ba42 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt @@ -17,7 +17,9 @@ 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 @@ -25,15 +27,38 @@ import androidx.tv.material3.CardDefaults import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import coil3.compose.AsyncImage -import com.lagradost.cloudstream3.tv.model.TvMockMediaItem +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.TvMediaItem + +private val PosterPlaceholder = Color(0xFF2A2A2E) +private const val PosterWidthPx = 400 +private const val PosterHeightPx = 600 @Composable fun TvMediaCard( - item: TvMockMediaItem, + item: TvMediaItem, onClick: () -> Unit, modifier: Modifier = Modifier, onFocused: (() -> 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() + Column( modifier = modifier.width(148.dp), ) { @@ -52,9 +77,11 @@ fun TvMediaCard( ) { Box(Modifier.fillMaxSize()) { AsyncImage( - model = item.posterUrl, + model = request, contentDescription = item.title, contentScale = ContentScale.Crop, + placeholder = placeholder, + error = placeholder, modifier = Modifier.fillMaxSize(), ) Box( 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..f5b0a556f6e --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt @@ -0,0 +1,189 @@ +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: always [TvMockCatalog.continueWatching] — HomeViewModel.getResumeWatching + * reads/writes DataStore + DOWNLOAD_HEADER_CACHE; Phase 3 forbids touching history persistence. + */ +class TvHomeRepository { + + 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 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) + + // Continue Watching: explicit mock (no read-only history without persistence side effects). + val continueWatching = TvMockCatalog.continueWatching + + val rails = listOfNotNull( + continueWatching, + trending, + movies ?: TvMockCatalog.movies, // keep Phase 2 slot; mark mock via catalog object + anime ?: TvMockCatalog.anime, + ).map { rail -> + // Ensure mock copies keep isMock=true when we fell back. + rail + } + + 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/home/TvContentRail.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt index 4f59b503b63..88bf52a4ef8 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt @@ -20,28 +20,31 @@ 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.TvMockRail +import com.lagradost.cloudstream3.tv.model.TvContentRail /** * Horizontal content rail with per-rail focus memory. - * [lastFocusedIndex] is owned by [TvHomeFocusState] so leaving/returning Home restores focus. + * 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: TvMockRail, + rail: TvContentRail, lastFocusedIndex: Int, onFocusedIndexChanged: (Int) -> Unit, modifier: Modifier = Modifier, restoreFocus: Boolean = false, onRestoreConsumed: () -> Unit = {}, ) { - val safeIndex = lastFocusedIndex.coerceIn(0, (rail.items.size - 1).coerceAtLeast(0)) + 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) { + LaunchedEffect(restoreFocus, rail.id, safeIndex, rail.items.size) { if (restoreFocus && focusRequesters.isNotEmpty()) { listState.scrollToItem(safeIndex) runCatching { focusRequesters[safeIndex].requestFocus() } @@ -54,7 +57,7 @@ fun TvContentRail( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = rail.title, + text = if (rail.isMock) "${rail.title} · Demo" else rail.title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(horizontal = 8.dp), 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 index ebe5203e394..33b1d94b628 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt @@ -19,7 +19,9 @@ 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 @@ -28,26 +30,49 @@ 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.TvMockMediaItem +import com.lagradost.cloudstream3.tv.model.TvMediaItem @Composable fun TvHeroSection( - hero: TvMockMediaItem, + hero: TvMediaItem, watchFocusRequester: FocusRequester, modifier: Modifier = Modifier, 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 = hero.backdropUrl, + model = request, contentDescription = null, contentScale = ContentScale.Crop, + placeholder = placeholder, + error = placeholder, modifier = Modifier.fillMaxSize(), ) Box( @@ -80,7 +105,7 @@ fun TvHeroSection( verticalArrangement = Arrangement.Bottom, ) { Text( - text = "FEATURED", + text = if (hero.isMock) "FEATURED · DEMO" else "FEATURED", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, ) @@ -98,15 +123,17 @@ fun TvHeroSection( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - 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), - ) + 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), @@ -136,11 +163,12 @@ fun TvHeroSection( } } -private fun buildMetadataLine(hero: TvMockMediaItem): String { +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 index 9b9ca44b1d7..ef250ff4335 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt @@ -1,26 +1,42 @@ 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.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 com.lagradost.cloudstream3.tv.model.TvMockCatalog -import com.lagradost.cloudstream3.tv.model.TvMockHomeState +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.TvFocusScale +import com.lagradost.cloudstream3.tv.model.TvHomeAction +import com.lagradost.cloudstream3.tv.model.TvHomeCatalog +import com.lagradost.cloudstream3.tv.model.TvHomeUiState /** * Hoisted Home focus memory — survives destination switches in [TvNavigationShell]. + * Indices are coerced against dynamic rail item counts after catalog loads. */ class TvHomeFocusState( val railFocusIndices: SnapshotStateMap = mutableStateMapOf(), @@ -34,6 +50,24 @@ class TvHomeFocusState( railFocusIndices[railId] = index 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 @@ -43,12 +77,66 @@ fun rememberTvHomeFocusState(): TvHomeFocusState = remember { TvHomeFocusState() fun TvHomeScreen( focusState: TvHomeFocusState, modifier: Modifier = Modifier, - state: TvMockHomeState = TvMockCatalog.home, + viewModel: TvHomeViewModel = viewModel(), +) { + val uiState by viewModel.state.collectAsState() + + when (val state = uiState) { + is TvHomeUiState.Loading -> TvHomeLoadingPane(modifier) + is TvHomeUiState.Content -> TvHomeContentPane( + catalog = state.catalog, + focusState = focusState, + modifier = modifier, + ) + is TvHomeUiState.Empty -> TvHomeStatusPane( + title = "Nothing here", + body = buildString { + append(state.message) + state.providerName?.let { append("\nProvider: $it") } + append("\n\nRetry 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, + ) + is TvHomeUiState.Error -> TvHomeStatusPane( + title = "Couldn't load Home", + body = state.message + + if (state.canUseMockFallback) { + "\n\nRetry when a homepage provider is ready, or load the demo catalog (explicit fallback)." + } 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, + ) + } +} + +@Composable +private fun TvHomeContentPane( + catalog: TvHomeCatalog, + focusState: TvHomeFocusState, + modifier: Modifier = Modifier, ) { val watchFocusRequester = remember { FocusRequester() } - // One-shot rail id to restore when re-entering Home after another destination. var pendingRestoreRailId by remember { mutableStateOf(null) } + val visibleRails = remember(catalog.rails) { catalog.rails.filter { it.items.isNotEmpty() } } + LaunchedEffect(catalog) { + focusState.pruneTo(catalog) + } + + // Composition-enter only (same pattern as Phase 2): first visit → hero; return → rail restore. LaunchedEffect(Unit) { if (!focusState.initialHeroFocusDone) { runCatching { watchFocusRequester.requestFocus() } @@ -63,13 +151,34 @@ fun TvHomeScreen( contentPadding = PaddingValues(bottom = 48.dp), verticalArrangement = Arrangement.spacedBy(28.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), + ) + } + } item(key = "hero") { TvHeroSection( - hero = state.hero, + hero = catalog.hero, watchFocusRequester = watchFocusRequester, + // Placeholders — no player / details in Phase 3. + onWatchNow = {}, + onDetails = {}, ) } - itemsIndexed(state.rails, key = { _, rail -> rail.id }) { _, rail -> + itemsIndexed(visibleRails, key = { _, rail -> rail.id }) { _, rail -> val shouldRestore = pendingRestoreRailId == rail.id TvContentRail( rail = rail, @@ -85,3 +194,65 @@ fun TvHomeScreen( } } } + +@Composable +private fun TvHomeLoadingPane(modifier: Modifier = Modifier) { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Loading catalog…", + style = MaterialTheme.typography.headlineSmall, + 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..d7e71289246 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt @@ -0,0 +1,71 @@ +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.TvHomeRepository +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]; no duplicate fetches on recomposition. + */ +class TvHomeViewModel( + private val repository: TvHomeRepository = TvHomeRepository(), +) : ViewModel(), + StateContainer by DefaultStateContainer(TvHomeUiState.Loading), + ActionHandler { + + private var loadJob: Job? = null + + init { + loadCatalog() + } + + override fun onAction(action: TvHomeAction) { + when (action) { + TvHomeAction.Retry -> loadCatalog() + TvHomeAction.UseMockFallback -> { + loadJob?.cancel() + updateState { + TvHomeUiState.Content(repository.mockFallbackCatalog()) + } + } + } + } + + private fun loadCatalog() { + loadJob?.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(result.providerName) + + is TvHomeRepository.LoadResult.Failure -> + TvHomeUiState.Error(result.message, canUseMockFallback = true) + } + } + } + } +} 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..3c12033a78f --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt @@ -0,0 +1,82 @@ +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, +) + +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.", + ) : TvHomeUiState + + data class Error( + val message: String, + val canUseMockFallback: Boolean = true, + ) : TvHomeUiState +} + +sealed interface TvHomeAction { + data object Retry : TvHomeAction + data object UseMockFallback : 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..5703b895bef --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockCatalog.kt @@ -0,0 +1,107 @@ +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", + 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, + ) + + 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, + ) +} diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt deleted file mode 100644 index 94ed9714e36..00000000000 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockModels.kt +++ /dev/null @@ -1,127 +0,0 @@ -package com.lagradost.cloudstream3.tv.model - -/** - * Minimal immutable mock models for Compose TV Phase 2 UI only. - * Not replacements for SearchResponse / LoadResponse. - */ -data class TvMockMediaItem( - val id: String, - val title: String, - val subtitle: String = "", - val posterUrl: String, - 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, -) - -data class TvMockRail( - val id: String, - val title: String, - val items: List, -) - -data class TvMockHomeState( - val hero: TvMockMediaItem, - val rails: List, -) - -enum class TvDestination(val label: String) { - Home("Home"), - Search("Search"), - Watchlist("Watchlist"), - Settings("Settings"), -} - -object TvMockCatalog { - // Public sample images (picsum) — no binaries committed. - private const val P = "https://picsum.photos/seed" - - val home: TvMockHomeState = TvMockHomeState( - hero = TvMockMediaItem( - id = "hero-nebula", - title = "Nebula Drift", - subtitle = "Original · Sci-Fi", - 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. Cinematic mock hero for Compose TV Phase 2.", - ), - rails = listOf( - TvMockRail( - id = "continue", - title = "Continue Watching", - 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), - ), - ), - TvMockRail( - id = "trending", - title = "Trending", - 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"), - ), - ), - TvMockRail( - id = "movies", - title = "Movies", - 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"), - ), - ), - TvMockRail( - id = "anime", - title = "Anime", - 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), - ), - ), - ), - ) - - private fun item( - id: String, - title: String, - subtitle: String, - seed: String, - year: Int? = null, - rating: String? = null, - runtime: String? = null, - progress: Float? = null, - ) = TvMockMediaItem( - 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, - ) -} 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 index e8e62f3d155..b190e8be9a6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -100,11 +100,11 @@ fun TvNavigationShell( TvDestination.Home -> TvHomeScreen(focusState = homeFocusState) TvDestination.Search -> TvPlaceholderPane( title = "Search", - body = "Phase 2 placeholder — search UI and providers land in a later phase.", + body = "Phase 3 placeholder — Search is out of scope (later phase).", ) TvDestination.Watchlist -> TvPlaceholderPane( title = "Watchlist", - body = "Phase 2 placeholder — no history / library wiring yet.", + body = "Phase 3 placeholder — Watchlist / History out of scope.", ) TvDestination.Settings -> { if (showFocusProbe) { diff --git a/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 930abf85b7b..9cba41576fe 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,12 +1,15 @@ -# Compose for TV — Phase 2 shell (mock) +# Compose for TV — Phase 3 Home catalog bridge -Structural Compose TV UI with **mock data only**. No APIRepository / player / plugins. +Read-only Home catalog via thin bridge. No Search / Watchlist / History / Details / Player. ## Architecture -`TvComposeProbeActivity` → `TvTheme` → `TvNavigationShell` → `TvHomeScreen` (hero + rails) +``` +APIRepository → TvHomeRepository → immutable TV models → TvHomeViewModel (StateContainer) + → TvHomeUiState → TvHomeScreen +``` -Destinations (visual): Home, Search, Watchlist, Settings. Home is real mock UI; others are placeholders. Phase 1 `TvProbeScreen` remains as a canary under Settings. +`TvComposeProbeActivity` → `TvTheme` → `TvNavigationShell` → `TvHomeScreen` ## Packages @@ -14,16 +17,27 @@ Destinations (visual): Home, Search, Watchlist, Settings. Home is real mock UI; tv/ TvComposeProbeActivity.kt TvTheme.kt - TvProbeScreen.kt # Phase 1 canary + TvProbeScreen.kt navigation/TvNavigationShell.kt - home/TvHomeScreen.kt, TvHeroSection.kt, TvContentRail.kt + home/TvHomeScreen.kt, TvHeroSection.kt, TvContentRail.kt, TvHomeViewModel.kt components/TvMediaCard.kt, TvFocusScale.kt - model/TvMockModels.kt + model/TvHomeModels.kt, TvMockCatalog.kt + data/TvMediaMapper.kt, TvHomeRepository.kt ``` -## How to open +## Rails honesty + +| Rail | Source | +|------|--------| +| Continue Watching | **Mock** — resume history needs DataStore / download-header cache (can write); Phase 3 forbids persistence touches | +| Trending / Movies / Anime | **Real** via `APIRepository.getMainPage(1)` when a homepage provider exists; else mock slot or explicit demo fallback | +| Hero | First suitable real item (poster as backdrop); else explicit mock hero | -Not a launcher. Debug builds: +## States + +`Loading` → `Content` | `Empty` | `Error` with D-pad **Retry** and explicit **Load demo catalog** (never silent fake success). + +## How to open ```bash adb shell am start -n com.lagradost.cloudstream3.debug/com.lagradost.cloudstream3.tv.TvComposeProbeActivity @@ -35,7 +49,6 @@ Or **Settings → Updates → Actions → Compose TV (debug)** (`BuildConfig.DEB ```bash ./gradlew :app:compileStableDebugKotlin -# or ./gradlew :app:assembleStableDebug ``` From 023e99af6c412a7d24df36655f0d0c1a4fb1920e Mon Sep 17 00:00:00 2001 From: sika200581 <180838598+sika200581@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:00:15 +0000 Subject: [PATCH 04/11] Add Compose for TV Phase 4 details via APIRepository.load Wire Home cards/hero Details through compact TvContentRef into TvDetailsRepository (real load flow) and immutable Details UI; Watch Now remains a stub with no player, and mock items never invent load IDs. --- .../cloudstream3/tv/TvComposeProbeActivity.kt | 2 +- .../cloudstream3/tv/components/TvMediaCard.kt | 8 +- .../cloudstream3/tv/data/TvDetailsMapper.kt | 87 ++++ .../tv/data/TvDetailsRepository.kt | 76 ++++ .../tv/details/TvDetailsScreen.kt | 388 ++++++++++++++++++ .../tv/details/TvDetailsViewModel.kt | 82 ++++ .../cloudstream3/tv/home/TvContentRail.kt | 5 +- .../cloudstream3/tv/home/TvHeroSection.kt | 4 +- .../cloudstream3/tv/home/TvHomeScreen.kt | 41 +- .../cloudstream3/tv/model/TvDetailsModels.kt | 79 ++++ .../tv/navigation/TvNavigationShell.kt | 64 ++- docs/TV_COMPOSE_PROBE.md | 44 +- 12 files changed, 843 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsMapper.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsRepository.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt index 173f1b5f6fe..b624bc86c74 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -7,7 +7,7 @@ import androidx.activity.enableEdgeToEdge import com.lagradost.cloudstream3.tv.navigation.TvNavigationShell /** - * Compose-for-TV host activity (Phase 3: read-only Home catalog bridge). + * Compose-for-TV host activity (Phase 4: Home catalog + Details via APIRepository.load; no player). * * Not registered as MAIN / LEANBACK_LAUNCHER — default phone + legacy TV startup unchanged. * 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 index fd60ad5ba42..d6cd6be70c5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt @@ -121,9 +121,13 @@ fun TvMediaCard( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (item.subtitle.isNotBlank()) { + val subtitleText = when { + item.isMock -> if (item.subtitle.isNotBlank()) "${item.subtitle} · Demo" else "Demo — unavailable" + else -> item.subtitle + } + if (subtitleText.isNotBlank()) { Text( - text = item.subtitle, + text = subtitleText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, 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..7a8758c68b9 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsMapper.kt @@ -0,0 +1,87 @@ +package com.lagradost.cloudstream3.tv.data + +import com.lagradost.cloudstream3.AnimeLoadResponse +import com.lagradost.cloudstream3.EpisodeResponse +import com.lagradost.cloudstream3.LiveStreamLoadResponse +import com.lagradost.cloudstream3.LoadResponse +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.TorrentLoadResponse +import com.lagradost.cloudstream3.TvSeriesLoadResponse +import com.lagradost.cloudstream3.tv.model.TvDetailsContent + +/** + * 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] + * - [AnimeLoadResponse] + * - [LiveStreamLoadResponse] + * - [TorrentLoadResponse] + * - other / unknown LoadResponse implementors → variantLabel "Other" + */ +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) + + 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(), + ) + } + + 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 + } +} 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..6ea5a7b077d --- /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, + * episode lists UI, fillers, AutoResume, applyMeta sync — Phase 4 is details presentation only. + */ +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/details/TvDetailsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt new file mode 100644 index 00000000000..4737f33f6ec --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt @@ -0,0 +1,388 @@ +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.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.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.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvDetailsAction +import com.lagradost.cloudstream3.tv.model.TvDetailsContent +import com.lagradost.cloudstream3.tv.model.TvDetailsUiState + +/** + * Cinematic Details screen. Loads via [TvDetailsViewModel] / [com.lagradost.cloudstream3.tv.data.TvDetailsRepository]. + * Watch Now is a stub callback only — no player. + */ +@Composable +fun TvDetailsScreen( + ref: TvContentRef, + onBack: () -> Unit, + modifier: Modifier = Modifier, + onWatchNow: (TvDetailsContent) -> Unit = {}, + viewModel: TvDetailsViewModel = viewModel( + key = "tv-details:${ref.apiName}|${ref.url}", + ), +) { + val uiState by viewModel.state.collectAsState() + + LaunchedEffect(ref) { + viewModel.onWatchNowStub = { + val content = (viewModel.state.value as? TvDetailsUiState.Content)?.details + if (content != null) onWatchNow(content) + } + 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( + details = state.details, + onWatchNow = { viewModel.onAction(TvDetailsAction.WatchNow) }, + onBack = onBack, + modifier = modifier, + ) + is TvDetailsUiState.Error -> TvDetailsErrorPane( + message = state.message, + titleHint = state.titleHint ?: ref.title.takeIf { it.isNotBlank() }, + onRetry = { viewModel.onAction(TvDetailsAction.Retry) }, + onBack = onBack, + modifier = modifier, + ) + } +} + +@Composable +private fun TvDetailsContentPane( + details: TvDetailsContent, + onWatchNow: () -> Unit, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val watchFocus = 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() + + LaunchedEffect(details.url) { + runCatching { watchFocus.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 = 720.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 = 8, + 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(16.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = onWatchNow, + enabled = !details.comingSoon, + modifier = Modifier.focusRequester(watchFocus), + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.HeroButtonFocused), + glow = ButtonDefaults.glow( + focusedGlow = Glow( + elevationColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + elevation = 12.dp, + ), + ), + ) { + Text(if (details.comingSoon) "Coming Soon" else "Watch Now") + } + Button( + onClick = onBack, + scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), + ) { + Text("Back") + } + } + Text( + text = "Watch Now is a stub in Phase 4 — no player.", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), + ) + } + } + } +} + +@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( + 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 = titleHint ?: "Couldn't load details", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = message, + 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..d352769a334 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt @@ -0,0 +1,82 @@ +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.TvDetailsUiState +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]; no duplicate fetches on recomposition. + * Watch Now is a clean stub callback surface — no player wiring. + */ +class TvDetailsViewModel( + private val repository: TvDetailsRepository = TvDetailsRepository(), +) : ViewModel(), + StateContainer by DefaultStateContainer(TvDetailsUiState.Loading()), + ActionHandler { + + private var loadJob: Job? = null + private var boundRef: TvContentRef? = null + + /** Invoked by the UI host for the stub Watch Now action (Phase 4: no player). */ + var onWatchNowStub: (() -> Unit)? = null + + fun bind(ref: TvContentRef) { + if (boundRef == ref && state.value !is TvDetailsUiState.Error) { + // Same ref and not in error — keep existing Content / Loading. + 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 // navigation owned by shell + TvDetailsAction.WatchNow -> onWatchNowStub?.invoke() + } + } + + 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 -> + TvDetailsUiState.Content(result.details) + + is TvDetailsRepository.LoadResult.Failure -> + TvDetailsUiState.Error( + message = result.message, + titleHint = ref.title.takeIf { it.isNotBlank() }, + ) + } + } + } + } +} 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 index 88bf52a4ef8..cddd6f8d7f5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt @@ -21,6 +21,7 @@ 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. @@ -33,6 +34,7 @@ fun TvContentRail( lastFocusedIndex: Int, onFocusedIndexChanged: (Int) -> Unit, modifier: Modifier = Modifier, + onItemClick: (TvMediaItem) -> Unit = {}, restoreFocus: Boolean = false, onRestoreConsumed: () -> Unit = {}, ) { @@ -72,9 +74,10 @@ fun TvContentRail( .focusGroup(), ) { itemsIndexed(rail.items, key = { _, item -> item.id }) { index, item -> + // Keep focusable even for mock (Continue Watching rail); click shows explicit unavailable. TvMediaCard( item = item, - onClick = {}, + onClick = { onItemClick(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 index 33b1d94b628..fe91215d971 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt @@ -43,6 +43,7 @@ fun TvHeroSection( hero: TvMediaItem, watchFocusRequester: FocusRequester, modifier: Modifier = Modifier, + detailsEnabled: Boolean = true, onWatchNow: () -> Unit = {}, onDetails: () -> Unit = {}, ) { @@ -154,9 +155,10 @@ fun TvHeroSection( } Button( onClick = onDetails, + enabled = detailsEnabled, scale = ButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), ) { - Text("Details") + Text(if (detailsEnabled) "Details" else "Demo — unavailable") } } } 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 index ef250ff4335..24aa6689b88 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt @@ -30,7 +30,9 @@ 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.model.TvContentRef import com.lagradost.cloudstream3.tv.model.TvHomeAction +import com.lagradost.cloudstream3.tv.model.TvMediaItem import com.lagradost.cloudstream3.tv.model.TvHomeCatalog import com.lagradost.cloudstream3.tv.model.TvHomeUiState @@ -77,15 +79,34 @@ fun rememberTvHomeFocusState(): TvHomeFocusState = remember { TvHomeFocusState() fun TvHomeScreen( focusState: TvHomeFocusState, modifier: Modifier = Modifier, + onOpenDetails: (TvContentRef) -> Unit = {}, viewModel: TvHomeViewModel = viewModel(), ) { val uiState by viewModel.state.collectAsState() + var demoNotice by remember { mutableStateOf(null) } + + fun openOrNotice(item: TvMediaItem) { + val ref = TvContentRef.fromMediaItem(item) + if (ref != null) { + demoNotice = null + onOpenDetails(ref) + } else { + demoNotice = if (item.isMock) { + "Demo item — details unavailable (never loads fake IDs)." + } else { + "Missing provider URL — cannot open details." + } + } + } when (val state = uiState) { is TvHomeUiState.Loading -> TvHomeLoadingPane(modifier) is TvHomeUiState.Content -> TvHomeContentPane( catalog = state.catalog, focusState = focusState, + demoNotice = demoNotice, + onOpenItem = ::openOrNotice, + onWatchNowStub = { /* Phase 4: clean stub — no player */ }, modifier = modifier, ) is TvHomeUiState.Empty -> TvHomeStatusPane( @@ -126,6 +147,9 @@ fun TvHomeScreen( private fun TvHomeContentPane( catalog: TvHomeCatalog, focusState: TvHomeFocusState, + demoNotice: String?, + onOpenItem: (TvMediaItem) -> Unit, + onWatchNowStub: () -> Unit, modifier: Modifier = Modifier, ) { val watchFocusRequester = remember { FocusRequester() } @@ -169,13 +193,23 @@ private fun TvHomeContentPane( ) } } + if (!demoNotice.isNullOrBlank()) { + item(key = "demo-notice") { + Text( + text = demoNotice, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } item(key = "hero") { TvHeroSection( hero = catalog.hero, watchFocusRequester = watchFocusRequester, - // Placeholders — no player / details in Phase 3. - onWatchNow = {}, - onDetails = {}, + detailsEnabled = TvContentRef.fromMediaItem(catalog.hero) != null, + onWatchNow = onWatchNowStub, + onDetails = { onOpenItem(catalog.hero) }, ) } itemsIndexed(visibleRails, key = { _, rail -> rail.id }) { _, rail -> @@ -184,6 +218,7 @@ private fun TvHomeContentPane( rail = rail, lastFocusedIndex = focusState.indexFor(rail.id), onFocusedIndexChanged = { index -> focusState.update(rail.id, index) }, + onItemClick = onOpenItem, restoreFocus = shouldRestore, onRestoreConsumed = { if (pendingRestoreRailId == rail.id) { 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..a244eaf9b85 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt @@ -0,0 +1,79 @@ +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 = "", +) { + 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, + ) + } + } +} + +/** 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?, +) + +sealed interface TvDetailsUiState { + data class Loading( + val titleHint: String? = null, + ) : TvDetailsUiState + + data class Content( + val details: TvDetailsContent, + ) : TvDetailsUiState + + data class Error( + val message: String, + val titleHint: String? = null, + ) : TvDetailsUiState +} + +sealed interface TvDetailsAction { + data object Retry : TvDetailsAction + data object Back : TvDetailsAction + /** Stub only — Phase 4 must not start a player. */ + data object WatchNow : TvDetailsAction +} 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 index b190e8be9a6..68d1f6d5a5f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -28,13 +28,16 @@ import androidx.tv.material3.WideButtonDefaults import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.tv.TvProbeScreen import com.lagradost.cloudstream3.tv.components.TvFocusScale +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 /** - * Structural Compose TV shell: left nav (compact → expands on focus) + destination content. - * Destinations switched via Compose state — no extra navigation library. + * Structural Compose TV shell: left nav + destination content. + * Phase 4: Details overlays Home via compact [TvContentRef] strings (not LoadResponse in nav state). + * Back from Details clears the ref so [TvHomeFocusState] restores focus. */ @Composable fun TvNavigationShell( @@ -45,6 +48,33 @@ fun TvNavigationShell( val homeFocusState = rememberTvHomeFocusState() 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) } + + val detailsRef = run { + val url = detailsUrl + val api = detailsApiName + if (!url.isNullOrBlank() && !api.isNullOrBlank()) { + TvContentRef(url = url, apiName = api, title = detailsTitle.orEmpty()) + } else { + null + } + } + + fun openDetails(ref: TvContentRef) { + detailsUrl = ref.url + detailsApiName = ref.apiName + detailsTitle = ref.title + } + + fun closeDetails() { + detailsUrl = null + detailsApiName = null + detailsTitle = null + } + NavigationDrawer( modifier = modifier.fillMaxSize(), drawerContent = { @@ -72,8 +102,9 @@ fun TvNavigationShell( TvDestination.Settings -> R.drawable.ic_outline_settings_24 } NavigationDrawerItem( - selected = selected == dest, + selected = selected == dest && detailsRef == null, onClick = { + closeDetails() destination = dest.name if (dest != TvDestination.Settings) showFocusProbe = false }, @@ -96,17 +127,30 @@ fun TvNavigationShell( .background(MaterialTheme.colorScheme.background) .padding(start = 8.dp, top = 8.dp, end = 24.dp, bottom = 8.dp), ) { - when (selected) { - TvDestination.Home -> TvHomeScreen(focusState = homeFocusState) - TvDestination.Search -> TvPlaceholderPane( + when { + detailsRef != null -> { + TvDetailsScreen( + ref = detailsRef, + onBack = { closeDetails() }, + // Phase 4: clean stub only — no GeneratorPlayer / CS3IPlayer / Media3. + onWatchNow = { /* stub */ }, + ) + } + selected == TvDestination.Home -> { + TvHomeScreen( + focusState = homeFocusState, + onOpenDetails = { openDetails(it) }, + ) + } + selected == TvDestination.Search -> TvPlaceholderPane( title = "Search", - body = "Phase 3 placeholder — Search is out of scope (later phase).", + body = "Phase 4 placeholder — Search is out of scope (later phase).", ) - TvDestination.Watchlist -> TvPlaceholderPane( + selected == TvDestination.Watchlist -> TvPlaceholderPane( title = "Watchlist", - body = "Phase 3 placeholder — Watchlist / History out of scope.", + body = "Phase 4 placeholder — Watchlist / History out of scope.", ) - TvDestination.Settings -> { + selected == TvDestination.Settings -> { if (showFocusProbe) { TvProbeScreen() } else { diff --git a/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 9cba41576fe..804a6f972da 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,41 +1,47 @@ -# Compose for TV — Phase 3 Home catalog bridge +# Compose for TV — Phase 4 Details (load bridge, no player) -Read-only Home catalog via thin bridge. No Search / Watchlist / History / Details / Player. +Home → compact content identity → `APIRepository.load` → `TvDetailsRepository` → immutable `TvDetailsUiState` → `TvDetailsScreen` → `onWatchNow` stub. ## Architecture ``` -APIRepository → TvHomeRepository → immutable TV models → TvHomeViewModel (StateContainer) - → TvHomeUiState → TvHomeScreen +SearchResponse / TvMediaItem → TvContentRef (url + apiName) + → TvDetailsRepository (APIHolder + SyncRedirector + APIRepository.load) + → TvDetailsMapper → TvDetailsContent + → TvDetailsViewModel (StateContainer) → TvDetailsUiState + → TvDetailsScreen ``` -`TvComposeProbeActivity` → `TvTheme` → `TvNavigationShell` → `TvHomeScreen` +`TvComposeProbeActivity` → `TvTheme` → `TvNavigationShell` (Details overlay via saveable url/apiName strings) → Home / Details ## Packages ``` tv/ - TvComposeProbeActivity.kt - TvTheme.kt - TvProbeScreen.kt + details/TvDetailsScreen.kt, TvDetailsViewModel.kt + data/TvDetailsRepository.kt, TvDetailsMapper.kt (+ Phase 3 Home bridge) + model/TvDetailsModels.kt (TvContentRef, TvDetailsContent, UiState) + home/… (cards + hero Details → openDetails) navigation/TvNavigationShell.kt - home/TvHomeScreen.kt, TvHeroSection.kt, TvContentRail.kt, TvHomeViewModel.kt - components/TvMediaCard.kt, TvFocusScale.kt - model/TvHomeModels.kt, TvMockCatalog.kt - data/TvMediaMapper.kt, TvHomeRepository.kt ``` -## Rails honesty +## Load flow (documented, not invented) -| Rail | Source | -|------|--------| -| Continue Watching | **Mock** — resume history needs DataStore / download-header cache (can write); Phase 3 forbids persistence touches | -| Trending / Movies / Anime | **Real** via `APIRepository.getMainPage(1)` when a homepage provider exists; else mock slot or explicit demo fallback | -| Hero | First suitable real item (poster as backdrop); else explicit mock hero | +Mirrors `ResultViewModel2.load` without DataStore / trailer / player side effects: + +1. Identity: `url` + `apiName` (same as ResultFragment bundles from SearchResponse) +2. Resolve API: `getApiFromNameNull` ?: `getApiFromUrlNull` +3. `SyncRedirector.redirect(url, api)` +4. `APIRepository(api).load(validUrl)` → `Resource` +5. Map Movie / TvSeries / Anime / LiveStream / Torrent / Other + +Mock / demo Home items never call load (no fake IDs). ## States -`Loading` → `Content` | `Empty` | `Error` with D-pad **Retry** and explicit **Load demo catalog** (never silent fake success). +`Loading` → `Content` | `Error` with D-pad **Retry** and **Back**. Back restores Home focus via `TvHomeFocusState`. + +Watch Now = stub only — no GeneratorPlayer / CS3IPlayer / Media3. ## How to open From 08a7f8b6f14802fefe542815f2408e75cf078580 Mon Sep 17 00:00:00 2001 From: sika200581 <180838598+sika200581@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:12:17 +0000 Subject: [PATCH 05/11] =?UTF-8?q?Add=20Compose=20for=20TV=20Phase=205=20Wa?= =?UTF-8?q?tch=20Now=20=E2=86=92=20existing=20GeneratorPlayer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Details Watch Now through an immutable TvPlaybackRequest and TvPlaybackBridge into RepoLinkGenerator + GeneratorPlayer without changing CS3IPlayer, extractors, or custom player UI. Movies only; series/anime/live/torrent stay disabled for Phase 6. --- .../cloudstream3/tv/TvComposeProbeActivity.kt | 50 +++- .../tv/details/TvDetailsScreen.kt | 19 +- .../tv/details/TvDetailsViewModel.kt | 4 +- .../cloudstream3/tv/model/TvDetailsModels.kt | 2 +- .../cloudstream3/tv/model/TvPlaybackModels.kt | 49 ++++ .../tv/navigation/TvNavigationShell.kt | 14 +- .../tv/playback/TvPlaybackBridge.kt | 225 ++++++++++++++++++ .../res/layout/activity_tv_compose_probe.xml | 17 ++ docs/TV_COMPOSE_PROBE.md | 78 +++--- 9 files changed, 406 insertions(+), 52 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/playback/TvPlaybackBridge.kt create mode 100644 app/src/main/res/layout/activity_tv_compose_probe.xml diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt index b624bc86c74..60c4e333a51 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -1,13 +1,24 @@ package com.lagradost.cloudstream3.tv import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent +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 4: Home catalog + Details via APIRepository.load; no player). + * Compose-for-TV host activity (Phase 5: 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. * @@ -18,14 +29,41 @@ import com.lagradost.cloudstream3.tv.navigation.TvNavigationShell * * Also available from Settings → Updates → Actions → "Compose TV (debug)" when BuildConfig.DEBUG. */ -class TvComposeProbeActivity : ComponentActivity() { +class TvComposeProbeActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { + loadThemes(this) enableEdgeToEdge() super.onCreate(savedInstanceState) - setContent { + CommonActivity.init(this) + setContentView(R.layout.activity_tv_compose_probe) + findViewById(R.id.tv_compose_host).setContent { TvTheme { - TvNavigationShell() + 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 Details Watch Now. + * Request stays immutable; no Activity / LoadResponse in Compose UiState. + */ + 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/details/TvDetailsScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt index 4737f33f6ec..69a505ce52d 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt @@ -53,10 +53,12 @@ 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.watchNowDisabledReason /** * Cinematic Details screen. Loads via [TvDetailsViewModel] / [com.lagradost.cloudstream3.tv.data.TvDetailsRepository]. - * Watch Now is a stub callback only — no player. + * Watch Now → Activity callback → [com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge] (movies). + * Non-movie types keep Watch Now disabled with a clear Phase 6 reason — no custom player UI. */ @Composable fun TvDetailsScreen( @@ -246,9 +248,11 @@ private fun TvDetailsContentPane( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { + val disabledReason = details.watchNowDisabledReason() + val watchEnabled = disabledReason == null Button( onClick = onWatchNow, - enabled = !details.comingSoon, + enabled = watchEnabled, modifier = Modifier.focusRequester(watchFocus), scale = ButtonDefaults.scale(focusedScale = TvFocusScale.HeroButtonFocused), glow = ButtonDefaults.glow( @@ -258,7 +262,13 @@ private fun TvDetailsContentPane( ), ), ) { - Text(if (details.comingSoon) "Coming Soon" else "Watch Now") + Text( + when { + details.comingSoon -> "Coming Soon" + !watchEnabled -> "Watch Now" + else -> "Watch Now" + } + ) } Button( onClick = onBack, @@ -268,7 +278,8 @@ private fun TvDetailsContentPane( } } Text( - text = "Watch Now is a stub in Phase 4 — no player.", + text = details.watchNowDisabledReason() + ?: "Watch Now starts existing CloudStream playback (GeneratorPlayer).", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f), ) 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 index d352769a334..c19ff33684b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.withContext /** * Lifecycle-aware Details state holder (MVI / [StateContainer]). * Loads once per [TvContentRef]; no duplicate fetches on recomposition. - * Watch Now is a clean stub callback surface — no player wiring. + * Watch Now forwards to Activity via [onWatchNowStub] (immutable request; no player in VM). */ class TvDetailsViewModel( private val repository: TvDetailsRepository = TvDetailsRepository(), @@ -29,7 +29,7 @@ class TvDetailsViewModel( private var loadJob: Job? = null private var boundRef: TvContentRef? = null - /** Invoked by the UI host for the stub Watch Now action (Phase 4: no player). */ + /** Invoked by the UI host for Watch Now — Activity builds TvPlaybackRequest / bridge. */ var onWatchNowStub: (() -> Unit)? = null fun bind(ref: TvContentRef) { 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 index a244eaf9b85..8e2e59e8a03 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt @@ -74,6 +74,6 @@ sealed interface TvDetailsUiState { sealed interface TvDetailsAction { data object Retry : TvDetailsAction data object Back : TvDetailsAction - /** Stub only — Phase 4 must not start a player. */ + /** Activity-level playback request (Phase 5 movies → GeneratorPlayer). */ data object WatchNow : TvDetailsAction } 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..d752dd89edf --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt @@ -0,0 +1,49 @@ +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). + */ +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, +) { + 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" + + 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, + ) + } +} + +/** Why Watch Now is not available for this content in Phase 5. */ +fun TvDetailsContent.watchNowDisabledReason(): String? = when { + comingSoon -> "Coming soon — not released yet" + variantLabel == "Movie" -> null + variantLabel == "TvSeries" -> "Series episode picker is Phase 6" + variantLabel == "Anime" -> "Anime episode / dub selection is Phase 6" + variantLabel == "LiveStream" -> "Live playback wiring deferred (Phase 6)" + variantLabel == "Torrent" -> "Torrent playback wiring deferred (Phase 6)" + else -> "Playback for $variantLabel is not supported in Phase 5" +} 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 index 68d1f6d5a5f..ef31c9ffba6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -33,15 +33,16 @@ 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 /** * Structural Compose TV shell: left nav + destination content. - * Phase 4: Details overlays Home via compact [TvContentRef] strings (not LoadResponse in nav state). - * Back from Details clears the ref so [TvHomeFocusState] restores focus. + * Phase 5: Watch Now → Activity callback [onPlaybackRequest] (no player inside Compose). */ @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) @@ -132,8 +133,9 @@ fun TvNavigationShell( TvDetailsScreen( ref = detailsRef, onBack = { closeDetails() }, - // Phase 4: clean stub only — no GeneratorPlayer / CS3IPlayer / Media3. - onWatchNow = { /* stub */ }, + onWatchNow = { details -> + onPlaybackRequest(TvPlaybackRequest.fromDetails(details)) + }, ) } selected == TvDestination.Home -> { @@ -144,11 +146,11 @@ fun TvNavigationShell( } selected == TvDestination.Search -> TvPlaceholderPane( title = "Search", - body = "Phase 4 placeholder — Search is out of scope (later phase).", + body = "Phase 5 placeholder — Search is out of scope (later phase).", ) selected == TvDestination.Watchlist -> TvPlaceholderPane( title = "Watchlist", - body = "Phase 4 placeholder — Watchlist / History out of scope.", + body = "Phase 5 placeholder — Watchlist / History out of scope.", ) selected == TvDestination.Settings -> { if (showFocusProbe) { 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..452e6ec0932 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/playback/TvPlaybackBridge.kt @@ -0,0 +1,225 @@ +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.CommonActivity.showToast +import com.lagradost.cloudstream3.MovieLoadResponse +import com.lagradost.cloudstream3.R +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. + * Adapts an immutable [TvPlaybackRequest] into the same path ResultViewModel2 uses for movies: + * + * ``` + * MovieLoadResponse + * → 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 5: movies only. Series / Anime / Live / Torrent are rejected with a clear reason. + */ +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 + } + + /** + * 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") + } + if (!request.isMovie) { + val reason = unsupportedReason(request.variantLabel) + Log.i(TAG, "Unsupported variant ${request.variantLabel}: $reason") + return LaunchResult.Unsupported(reason) + } + + val prepared = withContext(Dispatchers.IO) { + runCatching { prepareMovieGenerator(request) } + .onFailure { logError(it) } + .getOrElse { + return@withContext PrepResult.Failed( + it.message ?: "Failed to prepare movie 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" -> "Series episode picker is Phase 6" + "Anime" -> "Anime episode / dub selection is Phase 6" + "LiveStream" -> "Live playback wiring deferred (Phase 6)" + "Torrent" -> "Torrent playback wiring deferred (Phase 6)" + else -> "Playback for $variantLabel is not supported in Phase 5" + } + + /** Show a short toast for non-launch outcomes (Activity-level UX only). */ + 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 sealed interface PrepResult { + data class Ready( + val generator: RepoLinkGenerator, + val syncData: HashMap, + ) : PrepResult + + data class Failed(val message: String) : PrepResult + } + + /** + * Mirrors ResultViewModel2 movie branch: + * resolve API → SyncRedirector → APIRepository.load → MovieLoadResponse + * → buildResultEpisode → RepoLinkGenerator. + */ + private suspend fun prepareMovieGenerator(request: TvPlaybackRequest): PrepResult { + if (APIRepository.isInvalidData(request.url)) { + return PrepResult.Failed("Invalid content URL") + } + val api = getApiFromNameNull(request.apiName) ?: getApiFromUrlNull(request.url) + ?: return PrepResult.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 PrepResult.Failed( + validUrlResource.errorString.ifBlank { + "Failed to resolve content URL for ${request.apiName}" + }, + ) + } + is Resource.Loading -> request.url + } + + val load = APIRepository(api).load(validUrl) + val response = when (load) { + is Resource.Success -> load.value + is Resource.Failure -> { + return PrepResult.Failed( + load.errorString.ifBlank { "Failed to load ${request.title}" }, + ) + } + is Resource.Loading -> { + return PrepResult.Failed("Unexpected loading state from APIRepository.load") + } + } + + val movie = response as? MovieLoadResponse + ?: return PrepResult.Failed( + "Expected MovieLoadResponse, got ${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) + val syncData = HashMap(movie.syncData) + Log.i(TAG, "Prepared RepoLinkGenerator for movie id=${episode.id} api=${movie.apiName}") + return PrepResult.Ready(generator, syncData) + } + + /** Exact field mapping used by ResultViewModel2 for MovieLoadResponse. */ + 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 + } + // Same entry as ResultViewModel2 ACTION_PLAY_EPISODE_IN_PLAYER: + // GeneratorPlayer.newInstance(generator, index, syncData) + val args = GeneratorPlayer.newInstance(ready.generator, 0, ready.syncData) + val fragment = GeneratorPlayer().apply { arguments = args } + val fm = activity.supportFragmentManager + // Replace any leftover player, then push so Back / exitPlayer pops cleanly. + 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/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/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 804a6f972da..9ed91ac5ed0 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,47 +1,61 @@ -# Compose for TV — Phase 4 Details (load bridge, no player) +# Compose for TV — Phase 5 Watch Now → existing playback -Home → compact content identity → `APIRepository.load` → `TvDetailsRepository` → immutable `TvDetailsUiState` → `TvDetailsScreen` → `onWatchNow` stub. +Movies: Details Watch Now → immutable `TvPlaybackRequest` → Activity callback → `TvPlaybackBridge` → existing `GeneratorPlayer` / `RepoLinkGenerator` / `CS3IPlayer` / Media3. -## Architecture +**No** custom player UI, extractors, providers, or Media3 config changes. + +## Exact existing playback path (traced, not invented) ``` -SearchResponse / TvMediaItem → TvContentRef (url + apiName) - → TvDetailsRepository (APIHolder + SyncRedirector + APIRepository.load) - → TvDetailsMapper → TvDetailsContent - → TvDetailsViewModel (StateContainer) → TvDetailsUiState - → TvDetailsScreen +ResultFragmentTv.resultPlayMovieButton + → ResultViewModel2.handleAction(EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep)) + → getPlayerAction(ctx) → typically ACTION_PLAY_EPISODE_IN_PLAYER + → generator = RepoLinkGenerator(listOf(movieEpisode), page = currentResponse) + → activity.navigate(R.id.global_to_navigation_player, + GeneratorPlayer.newInstance(generator, index, syncData)) + → GeneratorPlayer reads uuid from companion generators map + → PlayerGeneratorViewModel.attachGenerator + loadLinks() + → RepoLinkGenerator.generateLinks → APIRepository.loadLinks → extractors + → CS3IPlayer / Media3 ``` -`TvComposeProbeActivity` → `TvTheme` → `TvNavigationShell` (Details overlay via saveable url/apiName strings) → Home / Details +Movie `ResultEpisode` is built from `MovieLoadResponse` via `buildResultEpisode` (id = `LoadResponse.getId()`, data = `dataUrl`). -## Packages +Compose TV Phase 5 reuses the same generator + `GeneratorPlayer.newInstance` entry; Fragment is hosted in `R.id.tv_player_container` (AppCompatActivity) instead of MainActivity NavHost. `exitPlayer` → `popCurrentPage` → `onBackPressed` pops the back stack. + +## Phase 5 wiring ``` -tv/ - details/TvDetailsScreen.kt, TvDetailsViewModel.kt - data/TvDetailsRepository.kt, TvDetailsMapper.kt (+ Phase 3 Home bridge) - model/TvDetailsModels.kt (TvContentRef, TvDetailsContent, UiState) - home/… (cards + hero Details → openDetails) - navigation/TvNavigationShell.kt +TvDetailsScreen Watch Now + → TvPlaybackRequest(url, apiName, title, variantLabel) // no LoadResponse/Activity in UiState + → TvComposeProbeActivity.onPlaybackRequest + → TvPlaybackBridge.launch + · reject mock / comingSoon / non-Movie (clear reason) + · API resolve + SyncRedirector + APIRepository.load (same as details) + · MovieLoadResponse → buildResultEpisode → RepoLinkGenerator + · GeneratorPlayer.newInstance → FragmentTransaction(tv_player_container) ``` -## Load flow (documented, not invented) - -Mirrors `ResultViewModel2.load` without DataStore / trailer / player side effects: - -1. Identity: `url` + `apiName` (same as ResultFragment bundles from SearchResponse) -2. Resolve API: `getApiFromNameNull` ?: `getApiFromUrlNull` -3. `SyncRedirector.redirect(url, api)` -4. `APIRepository(api).load(validUrl)` → `Resource` -5. Map Movie / TvSeries / Anime / LiveStream / Torrent / Other +## Unsupported (Phase 5) -Mock / demo Home items never call load (no fake IDs). +| Variant | Behavior | +|-----------|-----------------------------------------------| +| Movie | Watch Now enabled → existing path | +| TvSeries | Watch Now disabled — episode picker Phase 6 | +| Anime | Watch Now disabled — episode/dub Phase 6 | +| LiveStream| Watch Now disabled — deferred Phase 6 | +| Torrent | Watch Now disabled — deferred Phase 6 | +| Mock/demo | Never launches real playback | -## States - -`Loading` → `Content` | `Error` with D-pad **Retry** and **Back**. Back restores Home focus via `TvHomeFocusState`. +## Packages -Watch Now = stub only — no GeneratorPlayer / CS3IPlayer / Media3. +``` +tv/ + playback/TvPlaybackBridge.kt + model/TvPlaybackModels.kt + TvComposeProbeActivity.kt # AppCompat + activity_tv_compose_probe.xml + details/… home/… navigation/… +``` ## How to open @@ -49,8 +63,6 @@ Watch Now = stub only — no GeneratorPlayer / CS3IPlayer / Media3. adb shell am start -n com.lagradost.cloudstream3.debug/com.lagradost.cloudstream3.tv.TvComposeProbeActivity ``` -Or **Settings → Updates → Actions → Compose TV (debug)** (`BuildConfig.DEBUG` only). - ## Validate ```bash @@ -58,4 +70,4 @@ Or **Settings → Updates → Actions → Compose TV (debug)** (`BuildConfig.DEB ./gradlew :app:assembleStableDebug ``` -Ensure `app/src/main/java/com/lagradost/cloudstream3/tv/` has **no** `androidx.compose.material3` imports. +Ensure `tv/` has **no** `androidx.compose.material3` imports. Do **not** modify CS3IPlayer / GeneratorPlayer / extractors / library. From edc4d1fbfa6635f597870051e426865f82e2f457 Mon Sep 17 00:00:00 2001 From: sika200581 <180838598+sika200581@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:23:07 +0000 Subject: [PATCH 06/11] Add Compose for TV Phase 6 series/anime episode selection Map real LoadResponse Episode/SeasonData into immutable TvSeason/TvEpisode, add D-pad season+episode selector, and play via existing GeneratorPlayer path. --- .../cloudstream3/tv/TvComposeProbeActivity.kt | 5 +- .../cloudstream3/tv/data/TvDetailsMapper.kt | 177 +++++++++- .../tv/data/TvDetailsRepository.kt | 2 +- .../tv/details/TvDetailsScreen.kt | 95 ++++-- .../tv/details/TvDetailsViewModel.kt | 82 ++++- .../tv/details/TvEpisodeSelector.kt | 322 ++++++++++++++++++ .../cloudstream3/tv/model/TvDetailsModels.kt | 59 +++- .../cloudstream3/tv/model/TvEpisodeModels.kt | 117 +++++++ .../cloudstream3/tv/model/TvPlaybackModels.kt | 67 +++- .../tv/navigation/TvNavigationShell.kt | 10 +- .../tv/playback/TvPlaybackBridge.kt | 185 ++++++---- docs/TV_COMPOSE_PROBE.md | 81 ++--- 12 files changed, 1034 insertions(+), 168 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/details/TvEpisodeSelector.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvEpisodeModels.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt index 60c4e333a51..28f232b11ff 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -15,7 +15,7 @@ import com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge import kotlinx.coroutines.launch /** - * Compose-for-TV host activity (Phase 5: Watch Now → existing GeneratorPlayer). + * Compose-for-TV host activity (Phase 6: Watch Now / Play Episode → existing GeneratorPlayer). * * Layout: [R.layout.activity_tv_compose_probe] — * Compose shell + [R.id.tv_player_container] Fragment boundary for GeneratorPlayer. @@ -57,8 +57,9 @@ class TvComposeProbeActivity : AppCompatActivity() { CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) /** - * Activity-level callback from Details Watch Now. + * Activity-level callback from Details Watch Now / Play Episode. * Request stays immutable; no Activity / LoadResponse in Compose UiState. + * After GeneratorPlayer pops, Compose Details remains with selection preserved in ViewModel. */ private fun onPlaybackRequest(request: TvPlaybackRequest) { lifecycleScope.launch { 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 index 7a8758c68b9..6c63f1ba15a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsMapper.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsMapper.kt @@ -1,13 +1,21 @@ 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. @@ -15,11 +23,16 @@ import com.lagradost.cloudstream3.tv.model.TvDetailsContent * * Variants handled (all concrete LoadResponse types in MainAPI.kt): * - [MovieLoadResponse] - * - [TvSeriesLoadResponse] - * - [AnimeLoadResponse] + * - [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 { @@ -44,6 +57,8 @@ object TvDetailsMapper { } 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" }, @@ -64,6 +79,10 @@ object TvDetailsMapper { url = response.url, variantLabel = variantLabelOf(response), posterHeaders = response.posterHeaders?.toMap(), + dubGroups = dubGroups, + defaultDubStatusId = defaultDub, + defaultSeasonIndex = defaultSeason, + defaultEpisodeId = defaultEpisode, ) } @@ -84,4 +103,158 @@ object TvDetailsMapper { } 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 index 6ea5a7b077d..10fe508734b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvDetailsRepository.kt @@ -25,7 +25,7 @@ import com.lagradost.cloudstream3.ui.APIRepository * afterPluginsLoadedEvent. Callers should Retry after plugins settle. * * Intentionally omitted vs ResultViewModel2: DOWNLOAD_HEADER_CACHE writes, trailers, - * episode lists UI, fillers, AutoResume, applyMeta sync — Phase 4 is details presentation only. + * fillers, AutoResume watch-position writes, applyMeta sync — Phase 6 maps episodes read-only for TV selector. */ class TvDetailsRepository { 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 index 69a505ce52d..7816053d37e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt @@ -22,7 +22,9 @@ 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 @@ -53,19 +55,22 @@ 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] / [com.lagradost.cloudstream3.tv.data.TvDetailsRepository]. - * Watch Now → Activity callback → [com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge] (movies). - * Non-movie types keep Watch Now disabled with a clear Phase 6 reason — no custom player UI. + * 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, - onWatchNow: (TvDetailsContent) -> Unit = {}, + onPlaybackRequest: (TvPlaybackRequest) -> Unit = {}, viewModel: TvDetailsViewModel = viewModel( key = "tv-details:${ref.apiName}|${ref.url}", ), @@ -73,10 +78,7 @@ fun TvDetailsScreen( val uiState by viewModel.state.collectAsState() LaunchedEffect(ref) { - viewModel.onWatchNowStub = { - val content = (viewModel.state.value as? TvDetailsUiState.Content)?.details - if (content != null) onWatchNow(content) - } + viewModel.onPlaybackRequest = onPlaybackRequest viewModel.bind(ref) } @@ -89,8 +91,8 @@ fun TvDetailsScreen( modifier = modifier, ) is TvDetailsUiState.Content -> TvDetailsContentPane( - details = state.details, - onWatchNow = { viewModel.onAction(TvDetailsAction.WatchNow) }, + content = state, + onAction = viewModel::onAction, onBack = onBack, modifier = modifier, ) @@ -106,13 +108,14 @@ fun TvDetailsScreen( @Composable private fun TvDetailsContentPane( - details: TvDetailsContent, - onWatchNow: () -> Unit, + content: TvDetailsUiState.Content, + onAction: (TvDetailsAction) -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { + val details = content.details val context = LocalContext.current - val watchFocus = remember { FocusRequester() } + val primaryFocus = remember { FocusRequester() } val placeholder = ColorPainter(Color(0xFF1A1A1E)) val imageUrl = details.backdropUrl?.takeIf { it.isNotBlank() } ?: details.posterUrl?.takeIf { it.isNotBlank() } @@ -129,8 +132,11 @@ private fun TvDetailsContentPane( .httpHeaders(detailsHeaders(details)) .build() - LaunchedEffect(details.url) { - runCatching { watchFocus.requestFocus() } + 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()) { @@ -195,7 +201,7 @@ private fun TvDetailsContentPane( modifier = Modifier .weight(1f) .fillMaxHeight() - .widthIn(max = 720.dp), + .widthIn(max = 780.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( @@ -230,7 +236,7 @@ private fun TvDetailsContentPane( text = details.synopsis, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.92f), - maxLines = 8, + maxLines = 6, overflow = TextOverflow.Ellipsis, ) } @@ -243,17 +249,32 @@ private fun TvDetailsContentPane( overflow = TextOverflow.Ellipsis, ) } - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.height(12.dp)) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { val disabledReason = details.watchNowDisabledReason() - val watchEnabled = disabledReason == null + 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 = onWatchNow, - enabled = watchEnabled, - modifier = Modifier.focusRequester(watchFocus), + 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( @@ -262,13 +283,7 @@ private fun TvDetailsContentPane( ), ), ) { - Text( - when { - details.comingSoon -> "Coming Soon" - !watchEnabled -> "Watch Now" - else -> "Watch Now" - } - ) + Text(ctaLabel) } Button( onClick = onBack, @@ -279,10 +294,30 @@ private fun TvDetailsContentPane( } Text( text = details.watchNowDisabledReason() - ?: "Watch Now starts existing CloudStream playback (GeneratorPlayer).", + ?: 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 }, + ) + } } } } 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 index c19ff33684b..832a97e63d7 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt @@ -7,6 +7,8 @@ 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.TvDetailsUiState +import com.lagradost.cloudstream3.tv.model.TvEpisodeDefaults +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 @@ -17,8 +19,11 @@ import kotlinx.coroutines.withContext /** * Lifecycle-aware Details state holder (MVI / [StateContainer]). - * Loads once per [TvContentRef]; no duplicate fetches on recomposition. - * Watch Now forwards to Activity via [onWatchNowStub] (immutable request; no player in VM). + * 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 resume / DataStore writes for default episode. */ class TvDetailsViewModel( private val repository: TvDetailsRepository = TvDetailsRepository(), @@ -29,12 +34,11 @@ class TvDetailsViewModel( private var loadJob: Job? = null private var boundRef: TvContentRef? = null - /** Invoked by the UI host for Watch Now — Activity builds TvPlaybackRequest / bridge. */ - var onWatchNowStub: (() -> Unit)? = 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) { - // Same ref and not in error — keep existing Content / Loading. if (state.value is TvDetailsUiState.Content || state.value is TvDetailsUiState.Loading) { return } @@ -46,8 +50,65 @@ class TvDetailsViewModel( override fun onAction(action: TvDetailsAction) { when (action) { TvDetailsAction.Retry -> boundRef?.let { loadDetails(it) } - TvDetailsAction.Back -> Unit // navigation owned by shell - TvDetailsAction.WatchNow -> onWatchNowStub?.invoke() + 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) } } @@ -68,7 +129,12 @@ class TvDetailsViewModel( updateState { when (result) { is TvDetailsRepository.LoadResult.Success -> - TvDetailsUiState.Content(result.details) + TvDetailsUiState.Content( + details = result.details, + selectedDubStatusId = result.details.defaultDubStatusId, + selectedSeasonIndex = result.details.defaultSeasonIndex, + selectedEpisodeId = result.details.defaultEpisodeId, + ) is TvDetailsRepository.LoadResult.Failure -> TvDetailsUiState.Error( 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/model/TvDetailsModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt index 8e2e59e8a03..ea84cf7509b 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt @@ -54,7 +54,33 @@ data class TvDetailsContent( /** 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( @@ -63,7 +89,29 @@ sealed interface TvDetailsUiState { data class Content( val details: TvDetailsContent, - ) : TvDetailsUiState + 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, @@ -74,6 +122,11 @@ sealed interface TvDetailsUiState { sealed interface TvDetailsAction { data object Retry : TvDetailsAction data object Back : TvDetailsAction - /** Activity-level playback request (Phase 5 movies → GeneratorPlayer). */ + /** 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/TvPlaybackModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt index d752dd89edf..2894f5eb8cd 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvPlaybackModels.kt @@ -7,6 +7,10 @@ package com.lagradost.cloudstream3.tv.model * * [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, @@ -16,6 +20,21 @@ data class TvPlaybackRequest( 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" } @@ -24,6 +43,9 @@ data class TvPlaybackRequest( 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( @@ -34,16 +56,49 @@ data class TvPlaybackRequest( 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 Watch Now is not available for this content in Phase 5. */ +/** 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" -> "Series episode picker is Phase 6" - variantLabel == "Anime" -> "Anime episode / dub selection is Phase 6" - variantLabel == "LiveStream" -> "Live playback wiring deferred (Phase 6)" - variantLabel == "Torrent" -> "Torrent playback wiring deferred (Phase 6)" - else -> "Playback for $variantLabel is not supported in Phase 5" + 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/navigation/TvNavigationShell.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt index ef31c9ffba6..99d13d6dca2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -37,7 +37,7 @@ import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest /** * Structural Compose TV shell: left nav + destination content. - * Phase 5: Watch Now → Activity callback [onPlaybackRequest] (no player inside Compose). + * Phase 6: Watch Now / Play Episode → Activity callback [onPlaybackRequest] (no player inside Compose). */ @Composable fun TvNavigationShell( @@ -133,9 +133,7 @@ fun TvNavigationShell( TvDetailsScreen( ref = detailsRef, onBack = { closeDetails() }, - onWatchNow = { details -> - onPlaybackRequest(TvPlaybackRequest.fromDetails(details)) - }, + onPlaybackRequest = onPlaybackRequest, ) } selected == TvDestination.Home -> { @@ -146,11 +144,11 @@ fun TvNavigationShell( } selected == TvDestination.Search -> TvPlaceholderPane( title = "Search", - body = "Phase 5 placeholder — Search is out of scope (later phase).", + body = "Phase 6 placeholder — Search is out of scope (later phase).", ) selected == TvDestination.Watchlist -> TvPlaceholderPane( title = "Watchlist", - body = "Phase 5 placeholder — Watchlist / History out of scope.", + body = "Phase 6 placeholder — Watchlist / History out of scope.", ) selected == TvDestination.Settings -> { if (showFocusProbe) { 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 index 452e6ec0932..8c0da4708b6 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/playback/TvPlaybackBridge.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/playback/TvPlaybackBridge.kt @@ -4,9 +4,12 @@ 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 @@ -24,10 +27,9 @@ 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. - * Adapts an immutable [TvPlaybackRequest] into the same path ResultViewModel2 uses for movies: * * ``` - * MovieLoadResponse + * MovieLoadResponse | selected Episode fields * → buildResultEpisode (same fields as ResultViewModel2) * → RepoLinkGenerator(listOf(ep), page = loadResponse) * → GeneratorPlayer.newInstance(generator, index=0, syncData) @@ -36,7 +38,8 @@ import kotlinx.coroutines.withContext * → CS3IPlayer / Media3 (unchanged) * ``` * - * Phase 5: movies only. Series / Anime / Live / Torrent are rejected with a clear reason. + * 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" @@ -49,6 +52,20 @@ object TvPlaybackBridge { 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 @@ -65,19 +82,31 @@ object TvPlaybackBridge { if (request.comingSoon) { return LaunchResult.Unsupported("Coming soon — not released yet") } - if (!request.isMovie) { + + 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 { prepareMovieGenerator(request) } + runCatching { + if (request.isMovie) prepareMovieGenerator(request) + else prepareEpisodeGenerator(request) + } .onFailure { logError(it) } .getOrElse { - return@withContext PrepResult.Failed( - it.message ?: "Failed to prepare movie playback", - ) + PrepResult.Failed(it.message ?: "Failed to prepare playback") } } @@ -91,14 +120,12 @@ object TvPlaybackBridge { } fun unsupportedReason(variantLabel: String): String = when (variantLabel) { - "TvSeries" -> "Series episode picker is Phase 6" - "Anime" -> "Anime episode / dub selection is Phase 6" - "LiveStream" -> "Live playback wiring deferred (Phase 6)" - "Torrent" -> "Torrent playback wiring deferred (Phase 6)" - else -> "Playback for $variantLabel is not supported in Phase 5" + "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" } - /** Show a short toast for non-launch outcomes (Activity-level UX only). */ fun report(activity: FragmentActivity, result: LaunchResult) { when (result) { LaunchResult.Launched -> Unit @@ -111,26 +138,12 @@ object TvPlaybackBridge { } } - private sealed interface PrepResult { - data class Ready( - val generator: RepoLinkGenerator, - val syncData: HashMap, - ) : PrepResult - - data class Failed(val message: String) : PrepResult - } - - /** - * Mirrors ResultViewModel2 movie branch: - * resolve API → SyncRedirector → APIRepository.load → MovieLoadResponse - * → buildResultEpisode → RepoLinkGenerator. - */ - private suspend fun prepareMovieGenerator(request: TvPlaybackRequest): PrepResult { + private suspend fun resolveLoad(request: TvPlaybackRequest): LoadOutcome { if (APIRepository.isInvalidData(request.url)) { - return PrepResult.Failed("Invalid content URL") + return LoadOutcome.Failed("Invalid content URL") } val api = getApiFromNameNull(request.apiName) ?: getApiFromUrlNull(request.url) - ?: return PrepResult.Failed( + ?: return LoadOutcome.Failed( "This provider does not exist (${request.apiName}). Retry after plugins finish loading.", ) @@ -138,7 +151,7 @@ object TvPlaybackBridge { val validUrl = when (validUrlResource) { is Resource.Success -> validUrlResource.value is Resource.Failure -> { - return PrepResult.Failed( + return LoadOutcome.Failed( validUrlResource.errorString.ifBlank { "Failed to resolve content URL for ${request.apiName}" }, @@ -147,36 +160,90 @@ object TvPlaybackBridge { is Resource.Loading -> request.url } - val load = APIRepository(api).load(validUrl) - val response = when (load) { - is Resource.Success -> load.value - is Resource.Failure -> { - return PrepResult.Failed( - load.errorString.ifBlank { "Failed to load ${request.title}" }, - ) - } - is Resource.Loading -> { - return PrepResult.Failed("Unexpected loading state from APIRepository.load") + 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)) } } + } - val movie = response as? MovieLoadResponse - ?: return PrepResult.Failed( - "Expected MovieLoadResponse, got ${response::class.simpleName}", - ) + /** + * 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() - if (movie.dataUrl.isBlank()) { - return PrepResult.Failed("Movie has no playable data URL") + 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)) + } } - - val episode = movieToResultEpisode(movie) - val generator = RepoLinkGenerator(listOf(episode), page = movie) - val syncData = HashMap(movie.syncData) - Log.i(TAG, "Prepared RepoLinkGenerator for movie id=${episode.id} api=${movie.apiName}") - return PrepResult.Ready(generator, syncData) } - /** Exact field mapping used by ResultViewModel2 for MovieLoadResponse. */ private fun movieToResultEpisode(loadResponse: MovieLoadResponse) = buildResultEpisode( headerName = loadResponse.name, @@ -206,14 +273,14 @@ object TvPlaybackBridge { showToast(activity, "Player host missing in Activity layout", null) return@runOnUiThread } - // Same entry as ResultViewModel2 ACTION_PLAY_EPISODE_IN_PLAYER: - // GeneratorPlayer.newInstance(generator, index, syncData) val args = GeneratorPlayer.newInstance(ready.generator, 0, ready.syncData) val fragment = GeneratorPlayer().apply { arguments = args } val fm = activity.supportFragmentManager - // Replace any leftover player, then push so Back / exitPlayer pops cleanly. if (fm.findFragmentByTag(PLAYER_BACK_STACK) != null) { - fm.popBackStack(PLAYER_BACK_STACK, androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE) + fm.popBackStack( + PLAYER_BACK_STACK, + androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE, + ) } fm.beginTransaction() .replace(containerId, fragment, PLAYER_BACK_STACK) diff --git a/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 9ed91ac5ed0..0e81ecfbf7c 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,67 +1,46 @@ -# Compose for TV — Phase 5 Watch Now → existing playback +# Compose for TV — Phase 6 Series/Anime episode + season selection -Movies: Details Watch Now → immutable `TvPlaybackRequest` → Activity callback → `TvPlaybackBridge` → existing `GeneratorPlayer` / `RepoLinkGenerator` / `CS3IPlayer` / Media3. - -**No** custom player UI, extractors, providers, or Media3 config changes. - -## Exact existing playback path (traced, not invented) +Architecture: ``` -ResultFragmentTv.resultPlayMovieButton - → ResultViewModel2.handleAction(EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep)) - → getPlayerAction(ctx) → typically ACTION_PLAY_EPISODE_IN_PLAYER - → generator = RepoLinkGenerator(listOf(movieEpisode), page = currentResponse) - → activity.navigate(R.id.global_to_navigation_player, - GeneratorPlayer.newInstance(generator, index, syncData)) - → GeneratorPlayer reads uuid from companion generators map - → PlayerGeneratorViewModel.attachGenerator + loadLinks() - → RepoLinkGenerator.generateLinks → APIRepository.loadLinks → extractors - → CS3IPlayer / Media3 +LoadResponse → TvDetailsMapper → immutable TvSeason/TvEpisode + → TvDetailsUiState (seasons, selectedSeason, selectedEpisode) + → TvEpisodeSelector → TvPlaybackRequest → TvPlaybackBridge + → RepoLinkGenerator → GeneratorPlayer → CS3IPlayer ``` -Movie `ResultEpisode` is built from `MovieLoadResponse` via `buildResultEpisode` (id = `LoadResponse.getId()`, data = `dataUrl`). +## Domain episode model (inspected, not invented) -Compose TV Phase 5 reuses the same generator + `GeneratorPlayer.newInstance` entry; Fragment is hosted in `R.id.tv_player_container` (AppCompatActivity) instead of MainActivity NavHost. `exitPlayer` → `popCurrentPage` → `onBackPressed` pops the back stack. +From `library/.../MainAPI.kt`: -## Phase 5 wiring +| Type | Episodes | +|------|----------| +| `Episode` | `data`, `name?`, `season?`, `episode?`, `posterUrl?`, `score?`, `description?`, `date?`, `runTime?` (season/episode are **Int?** only) | +| `SeasonData` | `season: Int`, `name?`, `displaySeason?` | +| `TvSeriesLoadResponse` | `episodes: List`, `seasonNames: List?` | +| `AnimeLoadResponse` | `episodes: MutableMap>`, `seasonNames` | +| `DubStatus` | None(-1), Subbed(0), Dubbed(1) | -``` -TvDetailsScreen Watch Now - → TvPlaybackRequest(url, apiName, title, variantLabel) // no LoadResponse/Activity in UiState - → TvComposeProbeActivity.onPlaybackRequest - → TvPlaybackBridge.launch - · reject mock / comingSoon / non-Movie (clear reason) - · API resolve + SyncRedirector + APIRepository.load (same as details) - · MovieLoadResponse → buildResultEpisode → RepoLinkGenerator - · GeneratorPlayer.newInstance → FragmentTransaction(tv_player_container) -``` +Specials / missing season: `Episode.season == null` or `0` → TV seasonIndex **0**, label **"No Season"** (mirrors ResultViewModel2 / `R.string.no_season`). -## Unsupported (Phase 5) +Non-int episodes: **do not exist** in LoadResponse; null `episode` → `(listIndex + 1)`. -| Variant | Behavior | -|-----------|-----------------------------------------------| -| Movie | Watch Now enabled → existing path | -| TvSeries | Watch Now disabled — episode picker Phase 6 | -| Anime | Watch Now disabled — episode/dub Phase 6 | -| LiveStream| Watch Now disabled — deferred Phase 6 | -| Torrent | Watch Now disabled — deferred Phase 6 | -| Mock/demo | Never launches real playback | +## Default episode rule (no resume / DataStore writes) -## Packages +1. Dub: Subbed if non-empty, else Dubbed, else None, else first group with episodes. +2. Season: lowest `seasonIndex != 0` with episodes; else season 0. +3. Episode: first playable (`data` non-blank) in that season; else first episode. -``` -tv/ - playback/TvPlaybackBridge.kt - model/TvPlaybackModels.kt - TvComposeProbeActivity.kt # AppCompat + activity_tv_compose_probe.xml - details/… home/… navigation/… -``` +## Playback -## How to open +- **Movies**: Watch Now (Phase 5 path unchanged). +- **Series/Anime**: Play Episode → `TvPlaybackRequest.fromEpisode` → bridge builds `ResultEpisode` via `buildResultEpisode` → `RepoLinkGenerator(listOf(ep), page)` → `GeneratorPlayer` in `tv_player_container`. +- **Live / Torrent**: explicit unsupported toast. +- **Mock**: never plays. -```bash -adb shell am start -n com.lagradost.cloudstream3.debug/com.lagradost.cloudstream3.tv.TvComposeProbeActivity -``` +## Playback return + +GeneratorPlayer is on the Fragment back stack. Back / `exitPlayer` pops it; Compose Details ViewModel keeps dub/season/episode selection. Focus returns to Play Episode CTA (composition FocusRequester). ## Validate @@ -70,4 +49,4 @@ adb shell am start -n com.lagradost.cloudstream3.debug/com.lagradost.cloudstream ./gradlew :app:assembleStableDebug ``` -Ensure `tv/` has **no** `androidx.compose.material3` imports. Do **not** modify CS3IPlayer / GeneratorPlayer / extractors / library. +Prefer `app/.../tv/**` only. Do **not** modify library / plugins / extractors / CS3IPlayer / GeneratorPlayer / ResultFragmentTv / TV XML. From e67baf0fcc36bed0169c983810118bc4e631456e Mon Sep 17 00:00:00 2001 From: sika200581 <180838598+sika200581@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:29:35 +0000 Subject: [PATCH 07/11] Add Compose for TV Phase 7 real multi-provider search Wire TvSearchScreen through APIRepository.search into immutable TvSearchResult/TvContentRef so Search opens the same Details and playback path as Home. Explicit submit, cancel-safe loads, no history writes. --- .../cloudstream3/tv/TvComposeProbeActivity.kt | 2 +- .../cloudstream3/tv/data/TvSearchMapper.kt | 63 +++ .../tv/data/TvSearchRepository.kt | 177 +++++++++ .../cloudstream3/tv/model/TvSearchModels.kt | 76 ++++ .../tv/navigation/TvNavigationShell.kt | 17 +- .../cloudstream3/tv/search/TvSearchScreen.kt | 374 ++++++++++++++++++ .../tv/search/TvSearchViewModel.kt | 132 +++++++ docs/TV_COMPOSE_PROBE.md | 59 ++- 8 files changed, 862 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchMapper.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvSearchRepository.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSearchModels.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchScreen.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchViewModel.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt index 28f232b11ff..7ced10b48c4 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -15,7 +15,7 @@ import com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge import kotlinx.coroutines.launch /** - * Compose-for-TV host activity (Phase 6: Watch Now / Play Episode → existing GeneratorPlayer). + * Compose-for-TV host activity (Phase 7: Search + Watch Now / Play Episode → existing GeneratorPlayer). * * Layout: [R.layout.activity_tv_compose_probe] — * Compose shell + [R.id.tv_player_container] Fragment boundary for GeneratorPlayer. 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/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/navigation/TvNavigationShell.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt index 99d13d6dca2..69c6d5d50aa 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -34,10 +34,12 @@ 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.search.TvSearchScreen +import com.lagradost.cloudstream3.tv.search.rememberTvSearchFocusState /** * Structural Compose TV shell: left nav + destination content. - * Phase 6: Watch Now / Play Episode → Activity callback [onPlaybackRequest] (no player inside Compose). + * Phase 7: Search → same TvContentRef / TvDetailsScreen as Home; playback via [onPlaybackRequest]. */ @Composable fun TvNavigationShell( @@ -47,6 +49,7 @@ fun TvNavigationShell( var destination by rememberSaveable { mutableStateOf(TvDestination.Home.name) } val selected = runCatching { TvDestination.valueOf(destination) }.getOrDefault(TvDestination.Home) val homeFocusState = rememberTvHomeFocusState() + val searchFocusState = rememberTvSearchFocusState() var showFocusProbe by rememberSaveable { mutableStateOf(false) } // Compact saveable identity — never store SearchResponse / LoadResponse here. @@ -142,13 +145,15 @@ fun TvNavigationShell( onOpenDetails = { openDetails(it) }, ) } - selected == TvDestination.Search -> TvPlaceholderPane( - title = "Search", - body = "Phase 6 placeholder — Search is out of scope (later phase).", - ) + selected == TvDestination.Search -> { + TvSearchScreen( + focusState = searchFocusState, + onOpenDetails = { openDetails(it) }, + ) + } selected == TvDestination.Watchlist -> TvPlaceholderPane( title = "Watchlist", - body = "Phase 6 placeholder — Watchlist / History out of scope.", + body = "Phase 7 placeholder — Watchlist / History out of scope.", ) selected == TvDestination.Settings -> { if (showFocusProbe) { 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..fb73b98eac2 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchScreen.kt @@ -0,0 +1,374 @@ +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.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 = "No results", + body = buildString { + append(state.message) + if (state.providerCount > 0) { + append("\nSearched ${state.providerCount} provider(s).") + } + append("\n\nTry another query, or retry.") + }, + primaryLabel = "Retry", + onPrimary = { viewModel.onAction(TvSearchAction.Retry) }, + secondaryLabel = "Clear", + onSecondary = { + viewModel.onAction(TvSearchAction.Clear) + focusState.restoreToField = true + runCatching { fieldFocus.requestFocus() } + }, + ) + is TvSearchUiState.Error -> TvSearchStatusPane( + title = "Search failed", + body = state.message, + 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/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 0e81ecfbf7c..5ffcdf653b5 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,46 +1,43 @@ -# Compose for TV — Phase 6 Series/Anime episode + season selection +# Compose for TV — Phase 7 Real Search → same Details as Home Architecture: ``` -LoadResponse → TvDetailsMapper → immutable TvSeason/TvEpisode - → TvDetailsUiState (seasons, selectedSeason, selectedEpisode) - → TvEpisodeSelector → TvPlaybackRequest → TvPlaybackBridge - → RepoLinkGenerator → GeneratorPlayer → CS3IPlayer +TvSearchScreen → TvSearchViewModel (StateContainer) + → TvSearchRepository → APIRepository.search(query, page) + → TvSearchMapper → immutable TvSearchResult (+ TvContentRef) + → cards → existing TvDetailsScreen / TvDetailsRepository + → existing playback / episodes (Phase 5–6) ``` -## Domain episode model (inspected, not invented) +## Domain search APIs (inspected, not invented) -From `library/.../MainAPI.kt`: +From `APIRepository` / `SearchViewModel` / `MainAPI`: -| Type | Episodes | -|------|----------| -| `Episode` | `data`, `name?`, `season?`, `episode?`, `posterUrl?`, `score?`, `description?`, `date?`, `runTime?` (season/episode are **Int?** only) | -| `SeasonData` | `season: Int`, `name?`, `displaySeason?` | -| `TvSeriesLoadResponse` | `episodes: List`, `seasonNames: List?` | -| `AnimeLoadResponse` | `episodes: MutableMap>`, `seasonNames` | -| `DubStatus` | None(-1), Subbed(0), Dubbed(1) | +| API | Behavior | +|-----|----------| +| `APIRepository.search(query, page)` | `Resource`; empty query → Success(empty); timeout `searchTimeoutMs` | +| `APIRepository.quickSearch(query)` | providers with `hasQuickSearch` only | +| `SearchResponse` | retains `apiName` + `url` (+ name, poster, type, score, …) | +| Multi-provider | `APIHolder.apis` → `APIRepository`; parallel `amap`; cancel via job + generation | +| Partial failure | failed providers skipped; successes kept (`SearchViewModel`) | +| Mobile/TV legacy | `SearchFragment` submits on IME Done (`onQueryTextSubmit`); suggestions debounce 300ms only | +| History | `SEARCH_HISTORY_KEY` writes — **forbidden** in Phase 7 TV Compose | -Specials / missing season: `Episode.season == null` or `0` → TV seasonIndex **0**, label **"No Season"** (mirrors ResultViewModel2 / `R.string.no_season`). +Phase 7 uses **full `search(query, 1)` on explicit submit**, providers from read-only `DataStoreHelper.searchPreferenceProviders` (fallback: all APIs). No quickSearch, no history writes. -Non-int episodes: **do not exist** in LoadResponse; null `episode` → `(listIndex + 1)`. +## Details convergence -## Default episode rule (no resume / DataStore writes) +`TvSearchResult.contentRef` is the same `TvContentRef(url, apiName, title)` Home builds via `TvContentRef.fromMediaItem`. Shell opens the **same** `TvDetailsScreen` / `TvDetailsRepository` — no search-only details path. Mock never appears in search results; items missing url/apiName are dropped by the mapper. -1. Dub: Subbed if non-empty, else Dubbed, else None, else first group with episodes. -2. Season: lowest `seasonIndex != 0` with episodes; else season 0. -3. Episode: first playable (`data` non-blank) in that season; else first episode. +## UX -## Playback - -- **Movies**: Watch Now (Phase 5 path unchanged). -- **Series/Anime**: Play Episode → `TvPlaybackRequest.fromEpisode` → bridge builds `ResultEpisode` via `buildResultEpisode` → `RepoLinkGenerator(listOf(ep), page)` → `GeneratorPlayer` in `tv_player_container`. -- **Live / Torrent**: explicit unsupported toast. -- **Mock**: never plays. - -## Playback return - -GeneratorPlayer is on the Fragment back stack. Back / `exitPlayer` pops it; Compose Details ViewModel keeps dub/season/episode selection. Focus returns to Play Episode CTA (composition FocusRequester). +- TV-native large search field + Search / Clear buttons; IME Done submits. +- Explicit submit (not per-keystroke) — matches production SearchFragment. +- Cancel superseded searches; generation guard against stale overwrite. +- Empty / Error with Retry + Clear — never silent demo. +- Query + result focus preserved across Details round-trip (ViewModel + hoisted `TvSearchFocusState`). +- Lazy grid + Coil 3.3.0 via existing `TvMediaCard`. ## Validate @@ -49,4 +46,4 @@ GeneratorPlayer is on the Fragment back stack. Back / `exitPlayer` pops it; Comp ./gradlew :app:assembleStableDebug ``` -Prefer `app/.../tv/**` only. Do **not** modify library / plugins / extractors / CS3IPlayer / GeneratorPlayer / ResultFragmentTv / TV XML. +Prefer `app/.../tv/**` only. Do **not** modify library / plugins / extractors / CS3IPlayer / GeneratorPlayer / ResultFragmentTv / TV XML / Watchlist. From b188a1c45a7e831c8f5f4c6c8fd088a10463573c Mon Sep 17 00:00:00 2001 From: sika200581 <180838598+sika200581@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:39:49 +0000 Subject: [PATCH 08/11] Add Compose for TV Phase 8 read-only Continue Watching + Watchlist Bridge Local resume/bookmark/favorites DataStore reads into immutable TV models without calling HomeViewModel.getResumeWatching (cache restore write) or SyncRepo auth libraries; Watchlist opens shared Details for viewing only. --- .../tv/components/TvLifecycleResume.kt | 28 ++ .../tv/data/TvContinueWatchingRepository.kt | 88 ++++++ .../cloudstream3/tv/data/TvHomeRepository.kt | 33 +- .../tv/data/TvWatchlistRepository.kt | 150 +++++++++ .../cloudstream3/tv/home/TvHomeScreen.kt | 5 + .../cloudstream3/tv/home/TvHomeViewModel.kt | 30 +- .../cloudstream3/tv/model/TvHomeModels.kt | 2 + .../tv/model/TvUserStateModels.kt | 127 ++++++++ .../tv/navigation/TvNavigationShell.kt | 16 +- .../tv/watchlist/TvWatchlistScreen.kt | 290 ++++++++++++++++++ .../tv/watchlist/TvWatchlistViewModel.kt | 63 ++++ docs/TV_COMPOSE_PROBE.md | 66 ++-- 12 files changed, 855 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/components/TvLifecycleResume.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvWatchlistRepository.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistScreen.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistViewModel.kt 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/data/TvContinueWatchingRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt new file mode 100644 index 00000000000..3cb080cdf25 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt @@ -0,0 +1,88 @@ +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.downloader.DownloadObjects + +/** + * Pure read-only Continue Watching adapter. + * + * 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, + ) + } + + 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/TvHomeRepository.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt index f5b0a556f6e..f2807c3f637 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvHomeRepository.kt @@ -29,10 +29,14 @@ import com.lagradost.cloudstream3.utils.DataStoreHelper * Provider selection mirrors HomeViewModel (read-only): DataStoreHelper.currentHomePage * then first APIHolder.apis entry with hasMainPage. Does not write currentHomePage. * - * Continue Watching: always [TvMockCatalog.continueWatching] — HomeViewModel.getResumeWatching - * reads/writes DataStore + DOWNLOAD_HEADER_CACHE; Phase 3 forbids touching history persistence. + * 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 { +class TvHomeRepository( + private val continueWatchingRepository: TvContinueWatchingRepository = TvContinueWatchingRepository(), +) { sealed interface LoadResult { data class Success(val catalog: TvHomeCatalog) : LoadResult @@ -81,6 +85,18 @@ class TvHomeRepository { 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. */ @@ -108,18 +124,15 @@ class TvHomeRepository { val movies = pickMoviesRail(lists, allItems) val anime = pickAnimeRail(lists, allItems) - // Continue Watching: explicit mock (no read-only history without persistence side effects). - val continueWatching = TvMockCatalog.continueWatching + // 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, // keep Phase 2 slot; mark mock via catalog object + movies ?: TvMockCatalog.movies, anime ?: TvMockCatalog.anime, - ).map { rail -> - // Ensure mock copies keep isMock=true when we fell back. - rail - } + ) val hero = pickHero(trending, movies, anime, allItems) 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/home/TvHomeScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt index 24aa6689b88..82a93f75bea 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt @@ -30,6 +30,7 @@ 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.TvOnResume import com.lagradost.cloudstream3.tv.model.TvContentRef import com.lagradost.cloudstream3.tv.model.TvHomeAction import com.lagradost.cloudstream3.tv.model.TvMediaItem @@ -85,6 +86,10 @@ fun TvHomeScreen( val uiState by viewModel.state.collectAsState() var demoNotice by remember { mutableStateOf(null) } + // Refresh Continue Watching on enter / Activity resume (read-only; no homepage re-fetch). + LaunchedEffect(Unit) { viewModel.onAction(TvHomeAction.RefreshContinueWatching) } + TvOnResume { viewModel.onAction(TvHomeAction.RefreshContinueWatching) } + fun openOrNotice(item: TvMediaItem) { val ref = TvContentRef.fromMediaItem(item) if (ref != null) { 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 index d7e71289246..6cdab093a76 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt @@ -16,7 +16,8 @@ import kotlinx.coroutines.withContext /** * Lifecycle-aware Home state holder (MVI / [StateContainer]). - * Loads once from [TvHomeRepository]; no duplicate fetches on recomposition. + * Loads once from [TvHomeRepository]; Continue Watching refreshed on resume (read-only). + * No duplicate DataStore reads on recomposition — only enter / resume / Retry. */ class TvHomeViewModel( private val repository: TvHomeRepository = TvHomeRepository(), @@ -25,6 +26,7 @@ class TvHomeViewModel( ActionHandler { private var loadJob: Job? = null + private var cwRefreshJob: Job? = null init { loadCatalog() @@ -35,15 +37,18 @@ class TvHomeViewModel( TvHomeAction.Retry -> loadCatalog() TvHomeAction.UseMockFallback -> { loadJob?.cancel() + cwRefreshJob?.cancel() updateState { TvHomeUiState.Content(repository.mockFallbackCatalog()) } } + TvHomeAction.RefreshContinueWatching -> refreshContinueWatching() } } private fun loadCatalog() { loadJob?.cancel() + cwRefreshJob?.cancel() loadJob = viewModelScope.launch { updateState { TvHomeUiState.Loading } val result = try { @@ -68,4 +73,27 @@ class TvHomeViewModel( } } } + + /** 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) + return@launch + } + // Only apply if still Content with same provider catalog (avoid clobbering Retry). + val latest = state.value + if (latest is TvHomeUiState.Content && !latest.catalog.usingMockFallback) { + updateState { TvHomeUiState.Content(refreshed) } + } + } + } } 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 index 3c12033a78f..1d47708ee52 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt @@ -64,6 +64,8 @@ sealed interface 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 } enum class TvDestination(val label: String) { 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..89dc7efc2fe --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt @@ -0,0 +1,127 @@ +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 epSubtitle = buildList { + when { + season != null && episode != null -> add("S$season E$episode") + episode != null -> add("E$episode") + else -> typeLabel?.let { add(it) } + } + }.joinToString(" · ") + return TvMediaItem( + id = id, + title = title, + subtitle = epSubtitle, + posterUrl = posterUrl, + backdropUrl = posterUrl, + progressFraction = progressFraction, + apiName = apiName, + url = url, + typeLabel = typeLabel, + isMock = false, + ) + } +} + +/** 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 index 69c6d5d50aa..9eb57a209fb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -36,10 +36,13 @@ import com.lagradost.cloudstream3.tv.model.TvDestination import com.lagradost.cloudstream3.tv.model.TvPlaybackRequest import com.lagradost.cloudstream3.tv.search.TvSearchScreen 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 7: Search → same TvContentRef / TvDetailsScreen as Home; playback via [onPlaybackRequest]. + * Phase 8: Watchlist / Local Library (read-only) → same TvDetailsScreen as Home/Search. + * Continue Watching on Home opens Details first (not direct play). */ @Composable fun TvNavigationShell( @@ -50,6 +53,7 @@ fun TvNavigationShell( 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. @@ -151,10 +155,12 @@ fun TvNavigationShell( onOpenDetails = { openDetails(it) }, ) } - selected == TvDestination.Watchlist -> TvPlaceholderPane( - title = "Watchlist", - body = "Phase 7 placeholder — Watchlist / History out of scope.", - ) + selected == TvDestination.Watchlist -> { + TvWatchlistScreen( + focusState = watchlistFocusState, + onOpenDetails = { openDetails(it) }, + ) + } selected == TvDestination.Settings -> { if (showFocusProbe) { TvProbeScreen() 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..78ee3460d28 --- /dev/null +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistScreen.kt @@ -0,0 +1,290 @@ +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.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 = "Library is empty", + 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 -> TvWatchlistStatusPane( + title = "Couldn't load Library", + body = state.message, + 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/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 5ffcdf653b5..829cfbcce9c 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,43 +1,55 @@ -# Compose for TV — Phase 7 Real Search → same Details as Home +# Compose for TV — Phase 8 Read-only Continue Watching + Watchlist/Library Architecture: ``` -TvSearchScreen → TvSearchViewModel (StateContainer) - → TvSearchRepository → APIRepository.search(query, page) - → TvSearchMapper → immutable TvSearchResult (+ TvContentRef) - → cards → existing TvDetailsScreen / TvDetailsRepository - → existing playback / episodes (Phase 5–6) +Home Continue Watching + → TvContinueWatchingRepository (read-only DataStore + header cache) + → TvContinueWatchingItem → TvMediaItem / TvContentRef + → TvDetailsScreen (Details first, not direct play) + +Watchlist destination + → TvWatchlistRepository (Local list sources only) + → sections (Watching / Completed / On-Hold / Dropped / Plan to Watch / Favorites) + → TvWatchlistItem → same TvDetailsScreen ``` -## Domain search APIs (inspected, not invented) +## Architectural Q — pure read-only adapters? -From `APIRepository` / `SearchViewModel` / `MainAPI`: +**Yes** for both CW and Watchlist, with a custom CW path. -| API | Behavior | -|-----|----------| -| `APIRepository.search(query, page)` | `Resource`; empty query → Success(empty); timeout `searchTimeoutMs` | -| `APIRepository.quickSearch(query)` | providers with `hasQuickSearch` only | -| `SearchResponse` | retains `apiName` + `url` (+ name, poster, type, score, …) | -| Multi-provider | `APIHolder.apis` → `APIRepository`; parallel `amap`; cancel via job + generation | -| Partial failure | failed providers skipped; successes kept (`SearchViewModel`) | -| Mobile/TV legacy | `SearchFragment` submits on IME Done (`onQueryTextSubmit`); suggestions debounce 300ms only | -| History | `SEARCH_HISTORY_KEY` writes — **forbidden** in Phase 7 TV Compose | +### Continue Watching — exact read-only APIs -Phase 7 uses **full `search(query, 1)` on explicit submit**, providers from read-only `DataStoreHelper.searchPreferenceProviders` (fallback: all APIs). No quickSearch, no history writes. +| API | Role | +|-----|------| +| `DataStoreHelper.getAllResumeStateIds()` | list parent ids | +| `DataStoreHelper.getLastWatched(id)` | resume meta | +| `getKey(DOWNLOAD_HEADER_CACHE, parentId)` | name/url/apiName/poster/type | +| `getKey(DOWNLOAD_HEADER_CACHE_BACKUP, parentId)` | **read only** fallback if primary missing | +| `DataStoreHelper.getViewPos(episodeId)` | real progress only | -## Details convergence +**Blocked write path (not used):** `HomeViewModel.getResumeWatching()` can `setKey(DOWNLOAD_HEADER_CACHE, …)` when restoring from backup. Phase 8 does **not** call it and does **not** modify persistence to “fix” that. -`TvSearchResult.contentRef` is the same `TvContentRef(url, apiName, title)` Home builds via `TvContentRef.fromMediaItem`. Shell opens the **same** `TvDetailsScreen` / `TvDetailsRepository` — no search-only details path. Mock never appears in search results; items missing url/apiName are dropped by the mapper. +Empty CW → omit rail on real Home (never mix demo CW into live catalog). Full mock fallback still shows explicit demo CW. + +### Watchlist / Library — exact read-only APIs + +Same sources as `LocalList.library()`: + +| API | Role | +|-----|------| +| `getAllWatchStateIds()` + `getResultWatchState(id)` | WatchType buckets | +| `getBookmarkedData(id)` | bookmark card fields | +| `getAllFavorites()` | Favorites section | +| `getCurrentAccount()` / `currentAccount` | profile label only | + +Does **not** use SyncRepo / MAL / AniList / Simkl / Kitsu (auth + network), does **not** write `LAST_SYNC_API_KEY` or `librarySortingMode`. No remove/edit. No auth UI — remote sync simply omitted. ## UX -- TV-native large search field + Search / Clear buttons; IME Done submits. -- Explicit submit (not per-keystroke) — matches production SearchFragment. -- Cancel superseded searches; generation guard against stale overwrite. -- Empty / Error with Retry + Clear — never silent demo. -- Query + result focus preserved across Details round-trip (ViewModel + hoisted `TvSearchFocusState`). -- Lazy grid + Coil 3.3.0 via existing `TvMediaCard`. +- CW + Watchlist cards open shared Details; stale url/apiName → Details error OK. +- Load on enter / Activity resume; no polling; no DataStore reads on recomposition. +- Lazy rows/columns; focus memory hoisted like Search/Home. ## Validate @@ -46,4 +58,4 @@ Phase 7 uses **full `search(query, 1)` on explicit submit**, providers from read ./gradlew :app:assembleStableDebug ``` -Prefer `app/.../tv/**` only. Do **not** modify library / plugins / extractors / CS3IPlayer / GeneratorPlayer / ResultFragmentTv / TV XML / Watchlist. +Prefer `app/.../tv/**` only. No new persistence / DataStore keys / DB / sync. From 01c40fc0d738fd46088148cf7b2641fe218f4b0c Mon Sep 17 00:00:00 2001 From: sika200581 Date: Tue, 15 Sep 2026 12:48:35 +0000 Subject: [PATCH 09/11] Add Compose for TV Phase 9 Continue Watching true Resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire read-only CW fields to existing TvPlaybackRequest → TvPlaybackBridge: movies resume directly; series/anime resolve Episode.data via Details load then fromEpisode; otherwise shared Details with season/episode restore. No new persistence, player path, or Compose seek. --- .../cloudstream3/tv/TvComposeProbeActivity.kt | 8 +- .../tv/data/TvContinueWatchingResume.kt | 104 +++++++++++ .../tv/details/TvDetailsViewModel.kt | 50 +++++- .../cloudstream3/tv/home/TvHomeScreen.kt | 132 ++++++++++++-- .../tv/model/TvContinueWatchingPlayability.kt | 161 ++++++++++++++++++ .../cloudstream3/tv/model/TvDetailsModels.kt | 6 + .../cloudstream3/tv/model/TvHomeModels.kt | 5 + .../tv/model/TvUserStateModels.kt | 10 ++ .../tv/navigation/TvNavigationShell.kt | 51 +++++- docs/TV_COMPOSE_PROBE.md | 69 ++++---- 10 files changed, 527 insertions(+), 69 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingResume.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvContinueWatchingPlayability.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt index 7ced10b48c4..0aaae6327d2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -15,7 +15,7 @@ import com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge import kotlinx.coroutines.launch /** - * Compose-for-TV host activity (Phase 7: Search + Watch Now / Play Episode → existing GeneratorPlayer). + * Compose-for-TV host activity (Phase 9: CW Resume + Search/Details → existing GeneratorPlayer). * * Layout: [R.layout.activity_tv_compose_probe] — * Compose shell + [R.id.tv_player_container] Fragment boundary for GeneratorPlayer. @@ -57,9 +57,9 @@ class TvComposeProbeActivity : AppCompatActivity() { CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) /** - * Activity-level callback from Details Watch Now / Play Episode. - * Request stays immutable; no Activity / LoadResponse in Compose UiState. - * After GeneratorPlayer pops, Compose Details remains with selection preserved in ViewModel. + * 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 { 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/details/TvDetailsViewModel.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt index 832a97e63d7..0ce72694d8e 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsViewModel.kt @@ -6,8 +6,11 @@ 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 @@ -23,10 +26,14 @@ import kotlinx.coroutines.withContext * no FocusRequester / Activity in state. * * Playback: builds immutable [TvPlaybackRequest] and forwards via [onPlaybackRequest] - * (Activity → TvPlaybackBridge). No resume / DataStore writes for default episode. + * (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 { @@ -129,12 +136,7 @@ class TvDetailsViewModel( updateState { when (result) { is TvDetailsRepository.LoadResult.Success -> - TvDetailsUiState.Content( - details = result.details, - selectedDubStatusId = result.details.defaultDubStatusId, - selectedSeasonIndex = result.details.defaultSeasonIndex, - selectedEpisodeId = result.details.defaultEpisodeId, - ) + contentWithResumeRestore(result.details, ref.resumeHint) is TvDetailsRepository.LoadResult.Failure -> TvDetailsUiState.Error( @@ -145,4 +147,38 @@ class TvDetailsViewModel( } } } + + /** + * 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/home/TvHomeScreen.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt index 82a93f75bea..674c87dfbbe 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt @@ -15,6 +15,7 @@ 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 @@ -31,14 +32,22 @@ import androidx.tv.material3.WideButton import androidx.tv.material3.WideButtonDefaults 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.TvContentRef +import com.lagradost.cloudstream3.tv.model.TvContinueWatchingClassifier +import com.lagradost.cloudstream3.tv.model.TvCwClass import com.lagradost.cloudstream3.tv.model.TvHomeAction -import com.lagradost.cloudstream3.tv.model.TvMediaItem 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 [TvNavigationShell]. + * 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( @@ -81,22 +90,28 @@ fun TvHomeScreen( focusState: TvHomeFocusState, modifier: Modifier = Modifier, onOpenDetails: (TvContentRef) -> Unit = {}, + onPlaybackRequest: (TvPlaybackRequest) -> Unit = {}, viewModel: TvHomeViewModel = viewModel(), ) { val uiState by viewModel.state.collectAsState() - var demoNotice by remember { mutableStateOf(null) } + var notice by remember { mutableStateOf(null) } + var resolvingCwId by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + val resumeResolver = remember { TvContinueWatchingResume() } // Refresh Continue Watching on enter / Activity resume (read-only; no homepage re-fetch). LaunchedEffect(Unit) { viewModel.onAction(TvHomeAction.RefreshContinueWatching) } - TvOnResume { viewModel.onAction(TvHomeAction.RefreshContinueWatching) } + TvOnResume { + viewModel.onAction(TvHomeAction.RefreshContinueWatching) + } - fun openOrNotice(item: TvMediaItem) { + fun openDetailsOrNotice(item: TvMediaItem) { val ref = TvContentRef.fromMediaItem(item) if (ref != null) { - demoNotice = null + notice = null onOpenDetails(ref) } else { - demoNotice = if (item.isMock) { + notice = if (item.isMock) { "Demo item — details unavailable (never loads fake IDs)." } else { "Missing provider URL — cannot open details." @@ -104,13 +119,88 @@ fun TvHomeScreen( } } + fun onContinueWatchingClick(item: TvMediaItem) { + val classification = TvContinueWatchingClassifier.classifyMediaItem(item) + when (classification.clazz) { + TvCwClass.NotSafelyPlayable -> { + notice = classification.reason + } + TvCwClass.DirectPlayable -> { + val request = TvContinueWatchingClassifier.moviePlaybackRequest(item) + if (request == null || request.isMock) { + notice = "Demo item — never becomes a real TvPlaybackRequest." + } else { + notice = null + onPlaybackRequest(request) + } + } + TvCwClass.PlayableAfterDetails -> { + val ref = TvContentRef.fromMediaItem(item) + if (ref == null) { + notice = "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) { + if (resolvingCwId != null) return + resolvingCwId = item.id + 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 { + resolvingCwId = null + } + when (result) { + is TvContinueWatchingResume.ResolveResult.Playback -> { + if (result.request.isMock) { + notice = "Demo item — never becomes a real TvPlaybackRequest." + } else { + notice = null + onPlaybackRequest(result.request) + } + } + is TvContinueWatchingResume.ResolveResult.OpenDetails -> { + notice = result.message + onOpenDetails(result.ref.copy(resumeHint = hint)) + } + is TvContinueWatchingResume.ResolveResult.Unavailable -> { + notice = result.message + } + } + } + } else { + notice = null + onOpenDetails(ref) + } + } + } + } + + fun onRailItemClick(railId: String, item: TvMediaItem) { + if (railId == TvRailIds.CONTINUE) { + onContinueWatchingClick(item) + } else { + openDetailsOrNotice(item) + } + } + when (val state = uiState) { is TvHomeUiState.Loading -> TvHomeLoadingPane(modifier) is TvHomeUiState.Content -> TvHomeContentPane( catalog = state.catalog, focusState = focusState, - demoNotice = demoNotice, - onOpenItem = ::openOrNotice, + notice = notice, + onOpenItem = ::onRailItemClick, onWatchNowStub = { /* Phase 4: clean stub — no player */ }, modifier = modifier, ) @@ -152,8 +242,8 @@ fun TvHomeScreen( private fun TvHomeContentPane( catalog: TvHomeCatalog, focusState: TvHomeFocusState, - demoNotice: String?, - onOpenItem: (TvMediaItem) -> Unit, + notice: String?, + onOpenItem: (railId: String, item: TvMediaItem) -> Unit, onWatchNowStub: () -> Unit, modifier: Modifier = Modifier, ) { @@ -165,7 +255,7 @@ private fun TvHomeContentPane( focusState.pruneTo(catalog) } - // Composition-enter only (same pattern as Phase 2): first visit → hero; return → rail restore. + // Composition-enter: first visit → hero; return → rail restore. LaunchedEffect(Unit) { if (!focusState.initialHeroFocusDone) { runCatching { watchFocusRequester.requestFocus() } @@ -175,6 +265,14 @@ private fun TvHomeContentPane( } } + // After GeneratorPlayer pops (Activity ON_RESUME): restore CW / last rail focus; neighbor if gone (pruneTo). + TvOnResume { + val railId = focusState.lastFocusedRailId + if (railId != null) { + pendingRestoreRailId = railId + } + } + LazyColumn( modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 48.dp), @@ -198,10 +296,10 @@ private fun TvHomeContentPane( ) } } - if (!demoNotice.isNullOrBlank()) { - item(key = "demo-notice") { + if (!notice.isNullOrBlank()) { + item(key = "cw-notice") { Text( - text = demoNotice, + text = notice, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(horizontal = 8.dp), @@ -214,7 +312,7 @@ private fun TvHomeContentPane( watchFocusRequester = watchFocusRequester, detailsEnabled = TvContentRef.fromMediaItem(catalog.hero) != null, onWatchNow = onWatchNowStub, - onDetails = { onOpenItem(catalog.hero) }, + onDetails = { onOpenItem("hero", catalog.hero) }, ) } itemsIndexed(visibleRails, key = { _, rail -> rail.id }) { _, rail -> @@ -223,7 +321,7 @@ private fun TvHomeContentPane( rail = rail, lastFocusedIndex = focusState.indexFor(rail.id), onFocusedIndexChanged = { index -> focusState.update(rail.id, index) }, - onItemClick = onOpenItem, + onItemClick = { item -> onOpenItem(rail.id, item) }, restoreFocus = shouldRestore, onRestoreConsumed = { if (pendingRestoreRailId == rail.id) { 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 index ea84cf7509b..767d75850f5 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvDetailsModels.kt @@ -9,6 +9,11 @@ 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" } @@ -28,6 +33,7 @@ data class TvContentRef( url = url, apiName = apiName, title = item.title, + resumeHint = item.resumeHint, ) } } 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 index 1d47708ee52..dc41031db9c 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt @@ -24,6 +24,11 @@ data class TvMediaItem( 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, ) data class TvContentRail( 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 index 89dc7efc2fe..c2705c4640a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt @@ -28,12 +28,21 @@ data class TvContinueWatchingItem( ) fun toMediaItem(): TvMediaItem { + val classification = TvContinueWatchingClassifier.classify(this) 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) { + TvCwClass.DirectPlayable -> add("Resume") + TvCwClass.PlayableAfterDetails -> { + if (season != null || episode != null || episodeId != null) add("Continue") + else add("Details") + } + TvCwClass.NotSafelyPlayable -> add("Unavailable") + } }.joinToString(" · ") return TvMediaItem( id = id, @@ -46,6 +55,7 @@ data class TvContinueWatchingItem( url = url, typeLabel = typeLabel, isMock = false, + resumeHint = TvContinueWatchingClassifier.resumeHintOf(this), ) } } 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 index 9eb57a209fb..aac655c0a3a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -34,6 +34,7 @@ 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.search.rememberTvSearchFocusState import com.lagradost.cloudstream3.tv.watchlist.TvWatchlistScreen @@ -41,8 +42,8 @@ import com.lagradost.cloudstream3.tv.watchlist.rememberTvWatchlistFocusState /** * Structural Compose TV shell: left nav + destination content. - * Phase 8: Watchlist / Local Library (read-only) → same TvDetailsScreen as Home/Search. - * Continue Watching on Home opens Details first (not direct play). + * Phase 9: Continue Watching Resume — A direct movie play / B Details (+ series resolve) / C unavailable. + * Watchlist → same TvDetailsScreen. Mock never becomes real TvPlaybackRequest. */ @Composable fun TvNavigationShell( @@ -60,12 +61,35 @@ fun TvNavigationShell( 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()) { - TvContentRef(url = url, apiName = api, title = detailsTitle.orEmpty()) + 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 } @@ -75,12 +99,30 @@ fun TvNavigationShell( 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( @@ -140,13 +182,14 @@ fun TvNavigationShell( TvDetailsScreen( ref = detailsRef, onBack = { closeDetails() }, - onPlaybackRequest = onPlaybackRequest, + onPlaybackRequest = ::guardedPlayback, ) } selected == TvDestination.Home -> { TvHomeScreen( focusState = homeFocusState, onOpenDetails = { openDetails(it) }, + onPlaybackRequest = ::guardedPlayback, ) } selected == TvDestination.Search -> { diff --git a/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 829cfbcce9c..73bd18147a2 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,55 +1,50 @@ -# Compose for TV — Phase 8 Read-only Continue Watching + Watchlist/Library +# Compose for TV — Phase 9 Continue Watching true Resume Architecture: ``` -Home Continue Watching - → TvContinueWatchingRepository (read-only DataStore + header cache) - → TvContinueWatchingItem → TvMediaItem / TvContentRef - → TvDetailsScreen (Details first, not direct play) - -Watchlist destination - → TvWatchlistRepository (Local list sources only) - → sections (Watching / Completed / On-Hold / Dropped / Plan to Watch / Favorites) - → TvWatchlistItem → same TvDetailsScreen +Continue Watching (read-only Phase 8 fields) + → classify A / B / C + A Movie: TvPlaybackRequest(variantLabel=Movie) → TvPlaybackBridge → GeneratorPlayer + B Series/Anime + exact S/E or episodeId: + TvDetailsRepository.load → match TvEpisode → fromEpisode → same bridge + else → shared TvDetailsScreen (restore season/episode if possible) + C: clear unavailable — never play +Progress UI is display-only (PosDur). Player remains position owner. ``` -## Architectural Q — pure read-only adapters? +## Architectural Q — direct resume without second persistence? -**Yes** for both CW and Watchlist, with a custom CW path. +**Movies (A): Yes.** Exact path: -### Continue Watching — exact read-only APIs +`TvContinueWatchingItem { url, apiName, title, typeLabel=Movie }` +→ `TvPlaybackRequest(url, apiName, title, variantLabel="Movie")` +→ `TvPlaybackBridge.launch` → `APIRepository.load` → `MovieLoadResponse` → `RepoLinkGenerator` → `GeneratorPlayer` +(existing player seeks via PosDur / episode id — Compose does not seek) -| API | Role | -|-----|------| -| `DataStoreHelper.getAllResumeStateIds()` | list parent ids | -| `DataStoreHelper.getLastWatched(id)` | resume meta | -| `getKey(DOWNLOAD_HEADER_CACHE, parentId)` | name/url/apiName/poster/type | -| `getKey(DOWNLOAD_HEADER_CACHE_BACKUP, parentId)` | **read only** fallback if primary missing | -| `DataStoreHelper.getViewPos(episodeId)` | real progress only | +**Series/Anime: Not from CW fields alone.** Precise missing data: **`Episode.data`** (playable payload) plus full episode metadata required by `TvPlaybackRequest.fromEpisode` / bridge. CW only has `season?`, `episode?`, `episodeId?`, `parentId?` + header identity. -**Blocked write path (not used):** `HomeViewModel.getResumeWatching()` can `setKey(DOWNLOAD_HEADER_CACHE, …)` when restoring from backup. Phase 8 does **not** call it and does **not** modify persistence to “fix” that. +Exact path when S/E or episodeId present (still no new persistence): -Empty CW → omit rail on real Home (never mix demo CW into live catalog). Full mock fallback still shows explicit demo CW. +`TvContentRef` → **same** `TvDetailsRepository.load` as Details → match episode by `episodeId` else season+number → `TvPlaybackRequest.fromEpisode` → existing bridge. -### Watchlist / Library — exact read-only APIs +Do **not** fix the gap via DataStore writes, PosDur writers, or a second resume store. -Same sources as `LocalList.library()`: +## Phase 8 resume fields (audit) -| API | Role | -|-----|------| -| `getAllWatchStateIds()` + `getResultWatchState(id)` | WatchType buckets | -| `getBookmarkedData(id)` | bookmark card fields | -| `getAllFavorites()` | Favorites section | -| `getCurrentAccount()` / `currentAccount` | profile label only | +| Field | Source | +|-------|--------| +| title, url, apiName, posterUrl, typeLabel | `DOWNLOAD_HEADER_CACHE` (+ backup read-only) | +| episode, season, parentId, episodeId, updateTime | `ResumeWatching` / `getLastWatched` | +| progressFraction | `getViewPos(episodeId)` when duration > 0 | -Does **not** use SyncRepo / MAL / AniList / Simkl / Kitsu (auth + network), does **not** write `LAST_SYNC_API_KEY` or `librarySortingMode`. No remove/edit. No auth UI — remote sync simply omitted. +## Classification -## UX - -- CW + Watchlist cards open shared Details; stale url/apiName → Details error OK. -- Load on enter / Activity resume; no polling; no DataStore reads on recomposition. -- Lazy rows/columns; focus memory hoisted like Search/Home. +| Class | Criteria | +|-------|----------| +| **A Direct** | Non-blank url+apiName+title and `typeLabel == "Movie"` | +| **B After Details** | Series/Anime/Cartoon/AsianDrama/OVA (or other non-blocked types) with identity; restore/resolve when S/E or episodeId present | +| **C Unsafe** | Mock; Live; Torrent; missing identity | ## Validate @@ -58,4 +53,4 @@ Does **not** use SyncRepo / MAL / AniList / Simkl / Kitsu (auth + network), does ./gradlew :app:assembleStableDebug ``` -Prefer `app/.../tv/**` only. No new persistence / DataStore keys / DB / sync. +Prefer `app/.../tv/**` only. No new persistence / DataStore keys / player / library changes. From fa2b3ecc05ad55ce084201e2fab69ca37444e76d Mon Sep 17 00:00:00 2001 From: sika200581 Date: Tue, 15 Sep 2026 12:56:18 +0000 Subject: [PATCH 10/11] Add Compose for TV Phase 10 CW polish, stale handling, Hero Watch Now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve single TvPlaybackRequest → TvPlaybackBridge pipeline. CW remove uses existing removeLastWatched with TV confirm dialog; no new persistence. Hero Watch Now plays Movies directly, Series/Anime via Phase 9-safe resolve or Details; mock never plays. --- .../cloudstream3/tv/TvComposeProbeActivity.kt | 2 +- .../tv/components/TvConfirmDialog.kt | 95 ++++++ .../cloudstream3/tv/components/TvMediaCard.kt | 56 ++- .../tv/data/TvContinueWatchingRepository.kt | 18 +- .../tv/details/TvDetailsScreen.kt | 31 +- .../cloudstream3/tv/home/TvContentRail.kt | 3 +- .../cloudstream3/tv/home/TvHeroSection.kt | 4 +- .../cloudstream3/tv/home/TvHomeScreen.kt | 318 +++++++++++++----- .../cloudstream3/tv/home/TvHomeViewModel.kt | 61 +++- .../tv/model/TvAvailabilityModels.kt | 174 ++++++++++ .../cloudstream3/tv/model/TvHomeModels.kt | 12 + .../cloudstream3/tv/model/TvMockCatalog.kt | 4 +- .../tv/model/TvUserStateModels.kt | 14 +- .../tv/navigation/TvNavigationShell.kt | 2 +- .../cloudstream3/tv/search/TvSearchScreen.kt | 36 +- .../tv/watchlist/TvWatchlistScreen.kt | 21 +- docs/TV_COMPOSE_PROBE.md | 62 ++-- 17 files changed, 730 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/components/TvConfirmDialog.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvAvailabilityModels.kt diff --git a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt index 0aaae6327d2..6d5df180ecb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/TvComposeProbeActivity.kt @@ -15,7 +15,7 @@ import com.lagradost.cloudstream3.tv.playback.TvPlaybackBridge import kotlinx.coroutines.launch /** - * Compose-for-TV host activity (Phase 9: CW Resume + Search/Details → existing GeneratorPlayer). + * 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. 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/TvMediaCard.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt index d6cd6be70c5..a50ead72449 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/components/TvMediaCard.kt @@ -32,6 +32,7 @@ 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) @@ -44,6 +45,7 @@ fun TvMediaCard( onClick: () -> Unit, modifier: Modifier = Modifier, onFocused: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, ) { val context = LocalContext.current val placeholder = ColorPainter(PosterPlaceholder) @@ -59,11 +61,15 @@ fun TvMediaCard( ) .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) @@ -95,22 +101,44 @@ fun TvMediaCard( ), ), ) - item.progressFraction?.let { progress -> - Box( - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - .height(4.dp) - .background(Color.White.copy(alpha = 0.25f)), - ) { + // Progress only for valid resume — never fake progress on stale cards. + if (!stale) { + item.progressFraction?.let { progress -> Box( modifier = Modifier - .fillMaxWidth(progress.coerceIn(0f, 1f)) + .align(Alignment.BottomStart) + .fillMaxWidth() .height(4.dp) - .background(MaterialTheme.colorScheme.primary), - ) + .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)) @@ -129,7 +157,11 @@ fun TvMediaCard( Text( text = subtitleText, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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 index 3cb080cdf25..2900a0002ae 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/data/TvContinueWatchingRepository.kt @@ -10,10 +10,14 @@ 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 /** - * Pure read-only Continue Watching adapter. + * 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 @@ -50,6 +54,18 @@ class TvContinueWatchingRepository { ) } + + /** + * 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? { 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 index 7816053d37e..36e996f738f 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/details/TvDetailsScreen.kt @@ -51,6 +51,7 @@ 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 @@ -96,13 +97,17 @@ fun TvDetailsScreen( onBack = onBack, modifier = modifier, ) - is TvDetailsUiState.Error -> TvDetailsErrorPane( - message = state.message, - titleHint = state.titleHint ?: ref.title.takeIf { it.isNotBlank() }, - onRetry = { viewModel.onAction(TvDetailsAction.Retry) }, - 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, + ) + } } } @@ -361,6 +366,7 @@ private fun TvDetailsLoadingPane( @Composable private fun TvDetailsErrorPane( + availabilityTitle: String, message: String, titleHint: String?, onRetry: () -> Unit, @@ -378,12 +384,19 @@ private fun TvDetailsErrorPane( verticalArrangement = Arrangement.spacedBy(20.dp), ) { Text( - text = titleHint ?: "Couldn't load details", + 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, + text = message + "\n\nRetry or Back. No silent swap to other titles.", style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) 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 index cddd6f8d7f5..e7f8b07bf22 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvContentRail.kt @@ -35,6 +35,7 @@ fun TvContentRail( onFocusedIndexChanged: (Int) -> Unit, modifier: Modifier = Modifier, onItemClick: (TvMediaItem) -> Unit = {}, + onItemLongClick: ((TvMediaItem) -> Unit)? = null, restoreFocus: Boolean = false, onRestoreConsumed: () -> Unit = {}, ) { @@ -74,10 +75,10 @@ fun TvContentRail( .focusGroup(), ) { itemsIndexed(rail.items, key = { _, item -> item.id }) { index, item -> - // Keep focusable even for mock (Continue Watching rail); click shows explicit unavailable. 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 index fe91215d971..407e8ecba71 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHeroSection.kt @@ -44,6 +44,8 @@ fun TvHeroSection( watchFocusRequester: FocusRequester, modifier: Modifier = Modifier, detailsEnabled: Boolean = true, + /** Explicit label — real playable / Details / Demo. Never silent. */ + watchLabel: String = "Watch Now", onWatchNow: () -> Unit = {}, onDetails: () -> Unit = {}, ) { @@ -151,7 +153,7 @@ fun TvHeroSection( ), ), ) { - Text("Watch Now") + Text(watchLabel) } Button( onClick = onDetails, 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 index 674c87dfbbe..0f4644a49f0 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeScreen.kt @@ -30,12 +30,17 @@ 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 @@ -63,6 +68,13 @@ class TvHomeFocusState( 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 } @@ -85,6 +97,12 @@ class TvHomeFocusState( @Composable fun rememberTvHomeFocusState(): TvHomeFocusState = remember { TvHomeFocusState() } +private data class CwRemoveCandidate( + val item: TvMediaItem, + val parentId: Int, + val index: Int, +) + @Composable fun TvHomeScreen( focusState: TvHomeFocusState, @@ -95,11 +113,11 @@ fun TvHomeScreen( ) { val uiState by viewModel.state.collectAsState() var notice by remember { mutableStateOf(null) } - var resolvingCwId by remember { mutableStateOf(null) } + var resolvingId by remember { mutableStateOf(null) } + var removeCandidate by remember { mutableStateOf(null) } val scope = rememberCoroutineScope() val resumeResolver = remember { TvContinueWatchingResume() } - // Refresh Continue Watching on enter / Activity resume (read-only; no homepage re-fetch). LaunchedEffect(Unit) { viewModel.onAction(TvHomeAction.RefreshContinueWatching) } TvOnResume { viewModel.onAction(TvHomeAction.RefreshContinueWatching) @@ -112,23 +130,63 @@ fun TvHomeScreen( onOpenDetails(ref) } else { notice = if (item.isMock) { - "Demo item — details unavailable (never loads fake IDs)." + "${TvAvailabilityKind.PlaybackUnavailable.label}: Demo item — details unavailable (never loads fake IDs)." } else { - "Missing provider URL — cannot open details." + "${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 = classification.reason + notice = "${availability.kind.label}: ${classification.reason}" } TvCwClass.DirectPlayable -> { val request = TvContinueWatchingClassifier.moviePlaybackRequest(item) if (request == null || request.isMock) { - notice = "Demo item — never becomes a real TvPlaybackRequest." + notice = "${TvAvailabilityKind.PlaybackUnavailable.label}: Demo item — never becomes a real TvPlaybackRequest." } else { notice = null onPlaybackRequest(request) @@ -137,47 +195,14 @@ fun TvHomeScreen( TvCwClass.PlayableAfterDetails -> { val ref = TvContentRef.fromMediaItem(item) if (ref == null) { - notice = "Missing provider URL — cannot open details." + 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) { - if (resolvingCwId != null) return - resolvingCwId = item.id - 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 { - resolvingCwId = null - } - when (result) { - is TvContinueWatchingResume.ResolveResult.Playback -> { - if (result.request.isMock) { - notice = "Demo item — never becomes a real TvPlaybackRequest." - } else { - notice = null - onPlaybackRequest(result.request) - } - } - is TvContinueWatchingResume.ResolveResult.OpenDetails -> { - notice = result.message - onOpenDetails(result.ref.copy(resumeHint = hint)) - } - is TvContinueWatchingResume.ResolveResult.Unavailable -> { - notice = result.message - } - } - } + handleSeriesResolve(ref, hint, item.id) } else { notice = null onOpenDetails(ref) @@ -186,6 +211,37 @@ fun TvHomeScreen( } } + 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) @@ -194,47 +250,81 @@ fun TvHomeScreen( } } - when (val state = uiState) { - is TvHomeUiState.Loading -> TvHomeLoadingPane(modifier) - is TvHomeUiState.Content -> TvHomeContentPane( - catalog = state.catalog, - focusState = focusState, - notice = notice, - onOpenItem = ::onRailItemClick, - onWatchNowStub = { /* Phase 4: clean stub — no player */ }, - modifier = modifier, - ) - is TvHomeUiState.Empty -> TvHomeStatusPane( - title = "Nothing here", - body = buildString { - append(state.message) - state.providerName?.let { append("\nProvider: $it") } - append("\n\nRetry 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, - ) - is TvHomeUiState.Error -> TvHomeStatusPane( - title = "Couldn't load Home", - body = state.message + - if (state.canUseMockFallback) { - "\n\nRetry when a homepage provider is ready, or load the demo catalog (explicit fallback)." + 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 }, - 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, - ) + 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 + }, + ) + } } } @@ -244,7 +334,8 @@ private fun TvHomeContentPane( focusState: TvHomeFocusState, notice: String?, onOpenItem: (railId: String, item: TvMediaItem) -> Unit, - onWatchNowStub: () -> Unit, + onWatchNow: () -> Unit, + onCwLongClick: (TvMediaItem, Int) -> Unit, modifier: Modifier = Modifier, ) { val watchFocusRequester = remember { FocusRequester() } @@ -253,9 +344,14 @@ private fun TvHomeContentPane( 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 + } } - // Composition-enter: first visit → hero; return → rail restore. LaunchedEffect(Unit) { if (!focusState.initialHeroFocusDone) { runCatching { watchFocusRequester.requestFocus() } @@ -265,7 +361,6 @@ private fun TvHomeContentPane( } } - // After GeneratorPlayer pops (Activity ON_RESUME): restore CW / last rail focus; neighbor if gone (pruneTo). TvOnResume { val railId = focusState.lastFocusedRailId if (railId != null) { @@ -278,6 +373,24 @@ private fun TvHomeContentPane( 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 { @@ -307,11 +420,20 @@ private fun TvHomeContentPane( } } 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 = TvContentRef.fromMediaItem(catalog.hero) != null, - onWatchNow = onWatchNowStub, + detailsEnabled = heroRef != null, + watchLabel = watchLabel, + onWatchNow = onWatchNow, onDetails = { onOpenItem("hero", catalog.hero) }, ) } @@ -322,6 +444,15 @@ private fun TvHomeContentPane( 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) { @@ -339,11 +470,18 @@ private fun TvHomeLoadingPane(modifier: Modifier = Modifier) { modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - Text( - text = "Loading catalog…", - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + 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, + ) + } } } 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 index 6cdab093a76..49391886638 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/home/TvHomeViewModel.kt @@ -3,7 +3,10 @@ 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 @@ -16,11 +19,12 @@ import kotlinx.coroutines.withContext /** * Lifecycle-aware Home state holder (MVI / [StateContainer]). - * Loads once from [TvHomeRepository]; Continue Watching refreshed on resume (read-only). - * No duplicate DataStore reads on recomposition — only enter / resume / Retry. + * 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 { @@ -43,6 +47,7 @@ class TvHomeViewModel( } } TvHomeAction.RefreshContinueWatching -> refreshContinueWatching() + is TvHomeAction.RemoveContinueWatching -> removeContinueWatching(action.parentId) } } @@ -65,10 +70,19 @@ class TvHomeViewModel( TvHomeUiState.Content(result.catalog) is TvHomeRepository.LoadResult.Empty -> - TvHomeUiState.Empty(result.providerName) + TvHomeUiState.Empty( + providerName = result.providerName, + availability = TvAvailabilityKind.Unavailable, + ) - is TvHomeRepository.LoadResult.Failure -> - TvHomeUiState.Error(result.message, canUseMockFallback = true) + is TvHomeRepository.LoadResult.Failure -> { + val status = TvAvailabilityClassifier.fromHomeFailure(result.message) + TvHomeUiState.Error( + message = result.message, + canUseMockFallback = true, + availability = status.kind, + ) + } } } } @@ -87,13 +101,48 @@ class TvHomeViewModel( } } catch (t: Throwable) { logError(t) + // CW refresh failure must not swap catalog into demo. return@launch } - // Only apply if still Content with same provider catalog (avoid clobbering Retry). 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/TvHomeModels.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt index dc41031db9c..3a3c4acf125 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvHomeModels.kt @@ -29,6 +29,10 @@ data class TvMediaItem( * 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( @@ -58,11 +62,14 @@ sealed interface 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 } @@ -71,6 +78,11 @@ sealed interface 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) { 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 index 5703b895bef..da619f52aa1 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockCatalog.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvMockCatalog.kt @@ -10,7 +10,7 @@ object TvMockCatalog { val hero: TvMediaItem = TvMediaItem( id = "hero-nebula", title = "Nebula Drift", - subtitle = "Original · Sci-Fi", + subtitle = "Original · Sci-Fi · Demo", posterUrl = "$P/nebula-poster/400/600", backdropUrl = "$P/nebula-backdrop/1280/720", year = 2026, @@ -20,6 +20,7 @@ object TvMockCatalog { 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( @@ -103,5 +104,6 @@ object TvMockCatalog { runtime = runtime, progressFraction = progress, isMock = true, + availabilityKind = TvAvailabilityKind.PlaybackUnavailable, ) } 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 index c2705c4640a..0b8b98c12bb 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/model/TvUserStateModels.kt @@ -29,6 +29,7 @@ data class TvContinueWatchingItem( 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") @@ -36,12 +37,17 @@ data class TvContinueWatchingItem( 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") } - TvCwClass.NotSafelyPlayable -> add("Unavailable") + // 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( @@ -50,12 +56,16 @@ data class TvContinueWatchingItem( subtitle = epSubtitle, posterUrl = posterUrl, backdropUrl = posterUrl, - progressFraction = progressFraction, + // 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, ) } } 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 index aac655c0a3a..9e57d2f0e17 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -42,7 +42,7 @@ import com.lagradost.cloudstream3.tv.watchlist.rememberTvWatchlistFocusState /** * Structural Compose TV shell: left nav + destination content. - * Phase 9: Continue Watching Resume — A direct movie play / B Details (+ series resolve) / C unavailable. + * Phase 10: CW polish + remove + Hero Watch Now — single pipeline to TvPlaybackBridge. * Watchlist → same TvDetailsScreen. Mock never becomes real TvPlaybackRequest. */ @Composable 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 index fb73b98eac2..e9536c36f6a 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/search/TvSearchScreen.kt @@ -48,6 +48,8 @@ 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 /** @@ -141,26 +143,17 @@ fun TvSearchScreen( ) } is TvSearchUiState.Empty -> TvSearchStatusPane( - title = "No results", + title = TvAvailabilityKind.Unavailable.label, body = buildString { append(state.message) if (state.providerCount > 0) { append("\nSearched ${state.providerCount} provider(s).") } - append("\n\nTry another query, or retry.") - }, - primaryLabel = "Retry", - onPrimary = { viewModel.onAction(TvSearchAction.Retry) }, - secondaryLabel = "Clear", - onSecondary = { - viewModel.onAction(TvSearchAction.Clear) - focusState.restoreToField = true - runCatching { fieldFocus.requestFocus() } + 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.") }, - ) - is TvSearchUiState.Error -> TvSearchStatusPane( - title = "Search failed", - body = state.message, primaryLabel = "Retry", onPrimary = { viewModel.onAction(TvSearchAction.Retry) }, secondaryLabel = "Clear", @@ -170,6 +163,21 @@ fun TvSearchScreen( 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() } + }, + ) + } } } } 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 index 78ee3460d28..c5bd6df50a2 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistScreen.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/watchlist/TvWatchlistScreen.kt @@ -40,6 +40,8 @@ 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 /** @@ -99,7 +101,7 @@ fun TvWatchlistScreen( modifier = modifier, ) is TvWatchlistUiState.Empty -> TvWatchlistStatusPane( - title = "Library is empty", + title = TvAvailabilityKind.Unavailable.label, body = buildString { append(state.message) state.accountName?.let { append("\nProfile: $it") } @@ -109,13 +111,16 @@ fun TvWatchlistScreen( onPrimary = { viewModel.onAction(TvWatchlistAction.Retry) }, modifier = modifier, ) - is TvWatchlistUiState.Error -> TvWatchlistStatusPane( - title = "Couldn't load Library", - body = state.message, - 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, + ) + } } } diff --git a/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index 73bd18147a2..bf386494035 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,50 +1,40 @@ -# Compose for TV — Phase 9 Continue Watching true Resume +# Compose for TV — Phase 10 CW polish, stale handling, Hero Watch Now -Architecture: +Architecture (unchanged single pipeline): ``` -Continue Watching (read-only Phase 8 fields) - → classify A / B / C - A Movie: TvPlaybackRequest(variantLabel=Movie) → TvPlaybackBridge → GeneratorPlayer - B Series/Anime + exact S/E or episodeId: - TvDetailsRepository.load → match TvEpisode → fromEpisode → same bridge - else → shared TvDetailsScreen (restore season/episode if possible) - C: clear unavailable — never play -Progress UI is display-only (PosDur). Player remains position owner. +Home / Search / Watchlist / CW / Hero + → TvContentRef / TvPlaybackRequest + → TvPlaybackBridge + → GeneratorPlayer / RepoLinkGenerator / CS3IPlayer (existing) ``` -## Architectural Q — direct resume without second persistence? +## Architectural Q — single pipeline? New persistence? -**Movies (A): Yes.** Exact path: +**Yes, preserve one pipeline.** No new player path. -`TvContinueWatchingItem { url, apiName, title, typeLabel=Movie }` -→ `TvPlaybackRequest(url, apiName, title, variantLabel="Movie")` -→ `TvPlaybackBridge.launch` → `APIRepository.load` → `MovieLoadResponse` → `RepoLinkGenerator` → `GeneratorPlayer` -(existing player seeks via PosDur / episode id — Compose does not seek) +**Persistence: NO new keys / DataStore / DB / sync.** +CW remove only calls existing `DataStoreHelper.removeLastWatched(parentId)`. -**Series/Anime: Not from CW fields alone.** Precise missing data: **`Episode.data`** (playable payload) plus full episode metadata required by `TvPlaybackRequest.fromEpisode` / bridge. CW only has `season?`, `episode?`, `episodeId?`, `parentId?` + header identity. +## CW mutation API audit -Exact path when S/E or episodeId present (still no new persistence): +| API | Class | Notes | +|-----|-------|-------| +| `DataStoreHelper.removeLastWatched(parentId)` | **A reusable** | Per-item CW remove (mobile Home uses same) | +| `DataStoreHelper.deleteAllResumeStateIds()` | A (bulk) | Clear-all; not used for per-item TV UI | +| `setLastWatched` | write | Player/history write — not a hide API | +| `deleteBookmarkedData` / favorites / watch state | **B side-effecty** | Library mutations + refresh; not CW remove | +| Soft-hide / dismiss flag | **C none** | Would invent persistence — not implemented | -`TvContentRef` → **same** `TvDetailsRepository.load` as Details → match episode by `episodeId` else season+number → `TvPlaybackRequest.fromEpisode` → existing bridge. +**Remove UI:** yes (A + TV confirm dialog). **Hide:** no. -Do **not** fix the gap via DataStore writes, PosDur writers, or a second resume store. +## Phase 10 surfaces -## Phase 8 resume fields (audit) - -| Field | Source | -|-------|--------| -| title, url, apiName, posterUrl, typeLabel | `DOWNLOAD_HEADER_CACHE` (+ backup read-only) | -| episode, season, parentId, episodeId, updateTime | `ResumeWatching` / `getLastWatched` | -| progressFraction | `getViewPos(episodeId)` when duration > 0 | - -## Classification - -| Class | Criteria | -|-------|----------| -| **A Direct** | Non-blank url+apiName+title and `typeLabel == "Movie"` | -| **B After Details** | Series/Anime/Cartoon/AsianDrama/OVA (or other non-blocked types) with identity; restore/resolve when S/E or episodeId present | -| **C Unsafe** | Mock; Live; Torrent; missing identity | +1. **CW rail** — valid resume: poster/title/ep/progress/Resume; stale: availability badge, no fake play +2. **Remove** — long-press CW → confirm (Back cancels, focus Cancel) → `removeLastWatched` → refresh CW → neighbor focus +3. **Stale labels** — Available / Unavailable / Provider Missing / Load Failed / Playback Unavailable across Home/Search/Watchlist/Details/CW +4. **Hero Watch Now** — Movie → `TvPlaybackRequest` → bridge; Series/Anime → Phase 9 deterministic resolve only if exact episode hint, else Details; mock never plays +5. **Home state** — real / loading / empty / error / demo explicit; CW refresh never silent-fail→demo ## Validate @@ -53,4 +43,4 @@ Do **not** fix the gap via DataStore writes, PosDur writers, or a second resume ./gradlew :app:assembleStableDebug ``` -Prefer `app/.../tv/**` only. No new persistence / DataStore keys / player / library changes. +Prefer `app/.../tv/**` only. STOP after Phase 10. From 661891f005ab8e77f9beb820180dbfdfe2ce8712 Mon Sep 17 00:00:00 2001 From: sika200581 Date: Tue, 15 Sep 2026 13:04:45 +0000 Subject: [PATCH 11/11] Add Compose for TV Phase 11 Settings over existing preferences Wire TvSettingsScreen to AppSettings/PreferenceManager (same store as phone). Expose curated Playback/Appearance/Language/Downloads/App rows with TV dialogs; no parallel prefs, OAuth, or plugin UI. Keep Phase 2 DEBUG focus probe. --- .../tv/components/TvEnumDialog.kt | 126 +++++ .../cloudstream3/tv/model/TvSettingsModels.kt | 68 +++ .../tv/navigation/TvNavigationShell.kt | 47 +- .../tv/settings/TvSettingsAdapter.kt | 467 ++++++++++++++++++ .../tv/settings/TvSettingsScreen.kt | 300 +++++++++++ .../tv/settings/TvSettingsViewModel.kt | 75 +++ docs/TV_COMPOSE_PROBE.md | 48 +- 7 files changed, 1063 insertions(+), 68 deletions(-) create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/components/TvEnumDialog.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/model/TvSettingsModels.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsAdapter.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsScreen.kt create mode 100644 app/src/main/java/com/lagradost/cloudstream3/tv/settings/TvSettingsViewModel.kt 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/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/navigation/TvNavigationShell.kt b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt index 9e57d2f0e17..53ce3c35759 100644 --- a/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt +++ b/app/src/main/java/com/lagradost/cloudstream3/tv/navigation/TvNavigationShell.kt @@ -23,11 +23,8 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.NavigationDrawer import androidx.tv.material3.NavigationDrawerItem import androidx.tv.material3.Text -import androidx.tv.material3.WideButton -import androidx.tv.material3.WideButtonDefaults import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.tv.TvProbeScreen -import com.lagradost.cloudstream3.tv.components.TvFocusScale import com.lagradost.cloudstream3.tv.details.TvDetailsScreen import com.lagradost.cloudstream3.tv.home.TvHomeScreen import com.lagradost.cloudstream3.tv.home.rememberTvHomeFocusState @@ -36,13 +33,14 @@ 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 10: CW polish + remove + Hero Watch Now — single pipeline to TvPlaybackBridge. + * Phase 11: Settings over existing AppSettings; Phase 10 playback pipeline unchanged. * Watchlist → same TvDetailsScreen. Mock never becomes real TvPlaybackRequest. */ @Composable @@ -208,11 +206,8 @@ fun TvNavigationShell( if (showFocusProbe) { TvProbeScreen() } else { - TvPlaceholderPane( - title = "Settings", - body = "Compose TV settings shell (mock). Release launcher unchanged.", - actionLabel = "Open focus probe (canary)", - onAction = { showFocusProbe = true }, + TvSettingsScreen( + onOpenFocusProbe = { showFocusProbe = true }, ) } } @@ -220,37 +215,3 @@ fun TvNavigationShell( } } } - -@Composable -private fun TvPlaceholderPane( - title: String, - body: String, - actionLabel: String? = null, - onAction: (() -> Unit)? = null, -) { - 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, - ) - if (actionLabel != null && onAction != null) { - WideButton( - onClick = onAction, - scale = WideButtonDefaults.scale(focusedScale = TvFocusScale.ButtonFocused), - ) { - Text(actionLabel) - } - } - } -} 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/docs/TV_COMPOSE_PROBE.md b/docs/TV_COMPOSE_PROBE.md index bf386494035..d98d7ca5cf8 100644 --- a/docs/TV_COMPOSE_PROBE.md +++ b/docs/TV_COMPOSE_PROBE.md @@ -1,40 +1,38 @@ -# Compose for TV — Phase 10 CW polish, stale handling, Hero Watch Now +# Compose for TV — Phase 11 Settings over existing preferences -Architecture (unchanged single pipeline): +Architecture (unchanged playback pipeline): ``` -Home / Search / Watchlist / CW / Hero +Home / Search / Watchlist / CW / Hero / Details → TvContentRef / TvPlaybackRequest → TvPlaybackBridge → GeneratorPlayer / RepoLinkGenerator / CS3IPlayer (existing) ``` -## Architectural Q — single pipeline? New persistence? +Settings: -**Yes, preserve one pipeline.** No new player path. - -**Persistence: NO new keys / DataStore / DB / sync.** -CW remove only calls existing `DataStoreHelper.removeLastWatched(parentId)`. - -## CW mutation API audit +``` +TvSettingsScreen / TvSettingsAdapter + → AppSettings (PreferenceData) + → AndroidPreferenceStore + → PreferenceManager.getDefaultSharedPreferences (SAME as phone) +``` -| API | Class | Notes | -|-----|-------|-------| -| `DataStoreHelper.removeLastWatched(parentId)` | **A reusable** | Per-item CW remove (mobile Home uses same) | -| `DataStoreHelper.deleteAllResumeStateIds()` | A (bulk) | Clear-all; not used for per-item TV UI | -| `setLastWatched` | write | Player/history write — not a hide API | -| `deleteBookmarkedData` / favorites / watch state | **B side-effecty** | Library mutations + refresh; not CW remove | -| Soft-hide / dismiss flag | **C none** | Would invent persistence — not implemented | +## Architectural Q — same preference system? -**Remove UI:** yes (A + TV confirm dialog). **Hide:** no. +**Yes.** TV Settings uses `AppSettings` / `PreferenceData.set|get` only. +**No** second prefs store, **no** new DataStore keys, **no** parallel SharedPreferences file. -## Phase 10 surfaces +## Audit (safe-for-TV vs excluded) -1. **CW rail** — valid resume: poster/title/ep/progress/Resume; stale: availability badge, no fake play -2. **Remove** — long-press CW → confirm (Back cancels, focus Cancel) → `removeLastWatched` → refresh CW → neighbor focus -3. **Stale labels** — Available / Unavailable / Provider Missing / Load Failed / Playback Unavailable across Home/Search/Watchlist/Details/CW -4. **Hero Watch Now** — Movie → `TvPlaybackRequest` → bridge; Series/Anime → Phase 9 deterministic resolve only if exact episode hint, else Details; mock never plays -5. **Home state** — real / loading / empty / error / demo explicit; CW refresh never silent-fail→demo +| Class | Examples | TV Phase 11 | +|-------|----------|-------------| +| Safe-for-TV | autoplay, skip OP, episode sync, TV seek, show clock, DNS, downloads counts | Exposed | +| Restart-required | app locale | Exposed + confirm → `activity.recreate()` | +| Account | local profile name, skip account select | Read-only name / existing bool only | +| Mobile-only | gestures, PiP, rotate, brightness, battery opt, biometric | Excluded | +| Plugin / OAuth / backup file pickers / debug logcat | providers, MAL login, backup path | Excluded | +| Phase 2 DEBUG | focus probe | Kept behind `BuildConfig.DEBUG` action | ## Validate @@ -43,4 +41,4 @@ CW remove only calls existing `DataStoreHelper.removeLastWatched(parentId)`. ./gradlew :app:assembleStableDebug ``` -Prefer `app/.../tv/**` only. STOP after Phase 10. +Prefer `app/.../tv/**` only. STOP after Phase 11.