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.